Skip to main content

git_paw/
cli.rs

1//! CLI argument parsing.
2//!
3//! Defines the command-line interface using `clap` v4 with derive macros.
4//! All subcommands, flags, and options are declared here.
5
6use clap::{Parser, Subcommand, ValueEnum};
7
8/// Spec format selector for the `--specs-format` flag.
9///
10/// Three formats are supported:
11/// - `openspec` — `openspec/changes/<name>/` directory layout.
12/// - `markdown` — single-file Markdown specs with YAML frontmatter.
13/// - `speckit` — GitHub Spec Kit `.specify/specs/<feature>/` layout.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
15#[clap(rename_all = "lowercase")]
16pub enum SpecsFormat {
17    /// `OpenSpec` format (directory of `<change>/tasks.md`).
18    Openspec,
19    /// Markdown format (one `.md` file per spec with frontmatter).
20    Markdown,
21    /// Spec Kit format (`.specify/specs/<feature>/`).
22    Speckit,
23}
24
25impl SpecsFormat {
26    /// Returns the backend-dispatch string for this format.
27    #[must_use]
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Openspec => "openspec",
31            Self::Markdown => "markdown",
32            Self::Speckit => "speckit",
33        }
34    }
35}
36
37/// Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions
38/// across git worktrees from a single terminal using tmux.
39#[derive(Debug, Parser)]
40#[command(
41    name = "git-paw",
42    version,
43    about = "Parallel AI Worktrees — orchestrate multiple AI coding CLI sessions across git worktrees",
44    long_about = "git-paw orchestrates multiple AI coding CLI sessions (Claude, Codex, Gemini, etc.) \
45                  across git worktrees from a single terminal using tmux. Each branch gets its own \
46                  worktree and AI session, running in parallel.",
47    after_help = "\x1b[1mQuick Start:\x1b[0m\n\n  \
48                  # Launch interactive session (picks CLI and branches)\n  \
49                  git paw\n\n  \
50                  # Use Claude on specific branches\n  \
51                  git paw start --cli claude --branches feat/auth,feat/api\n\n  \
52                  # Check session status\n  \
53                  git paw status\n\n  \
54                  # Pause session (detaches client, stops broker, keeps CLIs alive)\n  \
55                  git paw pause\n\n  \
56                  # Stop session (kills CLIs, preserves worktrees for later)\n  \
57                  git paw stop\n\n  \
58                  # Remove everything\n  \
59                  git paw purge"
60)]
61pub struct Cli {
62    /// Subcommand to run. Defaults to `start` if omitted.
63    #[command(subcommand)]
64    pub command: Option<Command>,
65}
66
67/// Available subcommands.
68#[derive(Debug, Subcommand)]
69pub enum Command {
70    /// Launch a new session or reattach to an existing one
71    #[command(
72        about = "Launch a new session or reattach to an existing one",
73        long_about = "Smart start: reattaches if a session is active, recovers if stopped/crashed, \
74                      or launches a new interactive session.\n\n\
75                      By default, every existing agent branch is rebased onto the repository's \
76                      default branch (whatever `origin/HEAD` tracks — typically `main`) before \
77                      its worktree is opened, so agents always start from current main. Pass \
78                      `--no-rebase` to skip this step and reproduce the pre-v0.6 behaviour \
79                      (useful when you have local pinned SHAs or are deliberately working off a \
80                      stale baseline). If the rebase hits a conflict, the affected branch is \
81                      left at its pre-rebase HEAD and `git paw start` exits with an error \
82                      listing the conflicting files.\n\n\
83                      Examples:\n  \
84                      git paw start\n  \
85                      git paw start --cli claude\n  \
86                      git paw start --cli claude --branches feat/auth,feat/api\n  \
87                      git paw start --from-all-specs\n  \
88                      git paw start --from-all-specs --cli claude\n  \
89                      git paw start --specs add-auth,fix-session\n  \
90                      git paw start --specs   # opens spec picker (TTY required)\n  \
91                      git paw start --dry-run\n  \
92                      git paw start --preset backend\n  \
93                      git paw start --supervisor   # auto-approve safe prompts via [supervisor.auto_approve]\n  \
94                      git paw start --no-supervisor  # disable supervisor for this session (overrides config)\n  \
95                      git paw start --no-rebase   # skip rebasing agent branches onto the default branch"
96    )]
97    Start {
98        /// AI CLI to use (e.g., claude, codex, gemini). Skips CLI picker if provided.
99        #[arg(long, help = "AI CLI to use (skips CLI picker)")]
100        cli: Option<String>,
101
102        /// Comma-separated branch names. Skips branch picker if provided.
103        #[arg(
104            long,
105            value_delimiter = ',',
106            help = "Comma-separated branches (skips branch picker)"
107        )]
108        branches: Option<Vec<String>>,
109
110        /// Launch worktrees for every discovered spec across all configured formats.
111        #[arg(
112            long,
113            alias = "from-specs",
114            help = "Launch from every discovered spec across all configured formats"
115        )]
116        from_all_specs: bool,
117
118        /// Narrow the session to named specs, or open the multi-select picker
119        /// when given without values.
120        ///
121        /// `--specs add-auth,fix-session` runs only those specs. Bare `--specs`
122        /// opens a multi-select picker; an interactive terminal is required
123        /// (otherwise the command exits with an actionable error pointing at
124        /// `--specs NAME[,NAME...]` and `--from-all-specs`).
125        ///
126        /// Mutually exclusive with `--from-all-specs`.
127        #[arg(
128            long,
129            value_delimiter = ',',
130            num_args = 0..,
131            conflicts_with = "from_all_specs",
132            help = "Comma-separated spec names; bare flag opens picker (TTY required)"
133        )]
134        specs: Option<Vec<String>>,
135
136        /// Override the spec format used for `--from-all-specs` / `--specs` scanning.
137        ///
138        /// Accepted values: `openspec`, `markdown`, `speckit`. Overrides both
139        /// the `[specs] type` setting in `.git-paw/config.toml` and the
140        /// auto-detection of `.specify/` at the repo root.
141        #[arg(
142            long,
143            value_enum,
144            help = "Override spec format (openspec, markdown, speckit)"
145        )]
146        specs_format: Option<SpecsFormat>,
147
148        /// Preview the session plan without executing.
149        #[arg(long, help = "Preview the session plan without executing")]
150        dry_run: bool,
151
152        /// Use a named preset from config.
153        #[arg(long, help = "Use a named preset from config")]
154        preset: Option<String>,
155
156        /// Enable supervisor mode for this session.
157        #[arg(
158            long,
159            default_value_t = false,
160            help = "Enable supervisor mode for this session"
161        )]
162        supervisor: bool,
163
164        /// Disable supervisor mode for this session, overriding any config setting.
165        #[arg(
166            long,
167            conflicts_with = "supervisor",
168            default_value_t = false,
169            help = "Disable supervisor for this session, overriding any [supervisor] enabled = true in config"
170        )]
171        no_supervisor: bool,
172
173        /// Bypass uncommitted-spec validation warning.
174        #[arg(long, help = "Bypass uncommitted-spec validation warning")]
175        force: bool,
176
177        /// Skip rebasing existing agent branches onto the default branch
178        /// before opening their worktrees.
179        ///
180        /// By default, `git paw start` rebases every existing agent branch
181        /// onto the repository's default branch (whatever `origin/HEAD`
182        /// tracks, typically `main`) before opening or reopening its
183        /// worktree, so agents always start from current `main`. Pass
184        /// `--no-rebase` to skip the rebase step entirely and reproduce the
185        /// pre-v0.6 behaviour. Newly created branches (no prior commits) are
186        /// not rebased regardless of this flag.
187        #[arg(
188            long,
189            default_value_t = false,
190            help = "Skip rebasing existing agent branches onto the default branch before opening worktrees"
191        )]
192        no_rebase: bool,
193    },
194
195    /// Attach a new worktree + agent pane to a running session
196    #[command(
197        about = "Attach a new worktree + agent pane to a running session",
198        long_about = "Hot-attaches a worktree and agent pane to an already-running session — \
199                      no stop/purge/restart, the other agents keep working undisturbed. The \
200                      agent grid re-tiles to the layout a start of that many agents would \
201                      produce, the new branch is registered in the session, and the agent boots \
202                      with the same broker boot block + initial prompt a start-time agent gets.\n\n\
203                      Provide a branch name, or use --from-spec to derive the branch (and CLI) \
204                      from a discovered spec. Adding past the 25-agent cap is rejected. When the \
205                      session is paused, the new pane starts paused too and begins on the next \
206                      `git paw resume`. The supervisor (if any) discovers the new agent on its \
207                      next broker poll — no restart.\n\n\
208                      Examples:\n  \
209                      git paw add feat/new-thing\n  \
210                      git paw add feat/api --cli codex\n  \
211                      git paw add --from-spec add-export"
212    )]
213    Add {
214        /// Branch to attach. Omit when using --from-spec (the branch is
215        /// derived from the spec).
216        #[arg(
217            required_unless_present = "from_spec",
218            help = "Branch to attach (omit when using --from-spec)"
219        )]
220        branch: Option<String>,
221
222        /// AI CLI to launch in the new pane (defaults to the session's CLI).
223        #[arg(
224            long,
225            help = "AI CLI for the new pane (defaults to the session's default CLI)"
226        )]
227        cli: Option<String>,
228
229        /// Resolve the branch name and CLI from a discovered spec instead of a
230        /// positional branch argument.
231        #[arg(
232            long,
233            conflicts_with = "branch",
234            help = "Derive branch + CLI from a spec (OpenSpec change, Markdown spec, or Spec Kit feature)"
235        )]
236        from_spec: Option<String>,
237    },
238
239    /// Detach a single agent from a running session
240    #[command(
241        about = "Detach a single agent from a running session",
242        long_about = "Removes one agent from an active session: closes its tmux pane, re-tiles \
243                      the grid for the smaller agent count, removes its worktree (reusing \
244                      `git paw purge`'s per-worktree teardown), and drops it from the session. \
245                      The other agents are left untouched.\n\n\
246                      Safe by default: `remove` refuses to delete a worktree with uncommitted \
247                      changes (it lists what would be lost) unless you pass --force. Pass \
248                      --keep-worktree to detach the pane + session entry but leave the worktree \
249                      and branch on disk (this skips the uncommitted-work check, since nothing \
250                      is deleted). `remove supervisor` is refused — use `git paw stop` to end \
251                      the whole session. The supervisor notices the departure on its next broker \
252                      poll (the agent stops heartbeating) — no restart.\n\n\
253                      Examples:\n  \
254                      git paw remove feat/done-thing\n  \
255                      git paw remove feat/wip --force\n  \
256                      git paw remove feat/keep --keep-worktree"
257    )]
258    Remove {
259        /// Branch of the agent to remove.
260        #[arg(help = "Branch of the agent to remove")]
261        branch: String,
262
263        /// Detach the pane + session entry but leave the worktree and branch
264        /// on disk (skips the uncommitted-work safety check).
265        #[arg(
266            long,
267            help = "Leave the worktree + branch on disk; only detach the pane and session entry"
268        )]
269        keep_worktree: bool,
270
271        /// Remove the worktree even when it has uncommitted changes.
272        #[arg(
273            long,
274            help = "Remove even with uncommitted changes (bypass the safety check)"
275        )]
276        force: bool,
277    },
278
279    /// Pause the session (detaches client, stops broker, leaves CLIs running)
280    #[command(
281        about = "Pause the session (detaches client, stops broker, leaves CLIs running)",
282        long_about = "Detaches the tmux client and stops the broker, but leaves all CLI \
283                      processes running in the background. This preserves agent conversation \
284                      state for instant resume via `git paw start`. RAM stays allocated \
285                      (~300 MB per Claude pane).\n\n\
286                      Use pause for short breaks (lunch, meetings, end-of-day). For longer \
287                      breaks, use `git paw stop` to kill the CLIs and release RAM (worktrees \
288                      preserved). A future `git paw hibernate` (v1.0.0) will snapshot state \
289                      to disk.\n\n\
290                      Example:\n  git paw pause"
291    )]
292    Pause,
293
294    /// Stop the session (kills tmux, keeps worktrees and state)
295    #[command(
296        about = "Stop the session (kills tmux, keeps worktrees and state)",
297        long_about = "Kills the tmux session and every CLI pane process, but preserves \
298                      worktrees and session state on disk. CLI conversation context is lost. \
299                      Run `git paw start` later to recover the session with fresh CLI \
300                      processes.\n\n\
301                      Three teardown verbs:\n  \
302                      pause — soft stop (detach + broker stop; CLIs keep running, RAM held)\n  \
303                      stop  — kills CLI processes; preserves worktrees on disk (this command)\n  \
304                      purge — full reset; removes worktrees, branches, and state\n\n\
305                      `stop` prompts for confirmation in interactive terminals. Use \
306                      `--force` to skip the prompt (scripts) or pipe stdin from \
307                      `/dev/null` for non-interactive contexts.\n\n\
308                      Examples:\n  git paw stop\n  git paw stop --force"
309    )]
310    Stop {
311        /// Skip confirmation prompt.
312        #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
313        force: bool,
314    },
315
316    /// Remove everything (tmux session, worktrees, and state)
317    #[command(
318        about = "Remove everything (tmux session, worktrees, and state)",
319        long_about = "Nuclear option: kills the tmux session, removes all worktrees, and deletes \
320                      session state. Requires confirmation unless --force is used.\n\n\
321                      Use --stale to purge only sessions whose tmux session is gone (a stale \
322                      receipt). Live sessions are left untouched, so --stale is safe in cleanup \
323                      scripts. Pairing --stale with --force is a no-op (--force is redundant on \
324                      a stale entry).\n\n\
325                      Examples:\n  git paw purge\n  git paw purge --force\n  git paw purge --stale"
326    )]
327    Purge {
328        /// Skip confirmation prompt.
329        #[arg(long, help = "Skip confirmation prompt")]
330        force: bool,
331        /// Purge only stale sessions (receipt claims active but tmux is gone).
332        #[arg(
333            long,
334            help = "Purge only stale sessions (receipt claims active but tmux is gone); \
335                    live sessions untouched"
336        )]
337        stale: bool,
338    },
339
340    /// Show session state for the current repo
341    #[command(
342        about = "Show session state for the current repo",
343        long_about = "Displays the current session status, branches, CLIs, and worktree paths \
344                      for the repository in the current directory.\n\n\
345                      Status is one of 🟢 active (tmux running), 🔵 paused, 🟡 stopped, or \
346                      🔴 stale (the receipt claims active but the tmux session no longer \
347                      exists — a crash or release-boundary carry-over). Run `git paw start` to \
348                      self-heal a stale receipt, or `git paw purge --stale` to clear it.\n\n\
349                      Pass --json for machine-readable output (the `status` field is one of \
350                      active/paused/stopped/stale).\n\n\
351                      Examples:\n  git paw status\n  git paw status --json"
352    )]
353    Status {
354        /// Emit machine-readable JSON instead of the human-readable display.
355        #[arg(long, help = "Emit machine-readable JSON")]
356        json: bool,
357    },
358
359    /// List detected and custom AI CLIs
360    #[command(
361        about = "List detected and custom AI CLIs",
362        long_about = "Shows all AI CLIs found on PATH (auto-detected) and any custom CLIs \
363                      registered in your config.\n\n\
364                      Example:\n  git paw list-clis"
365    )]
366    ListClis,
367
368    /// Register a custom AI CLI
369    #[command(
370        about = "Register a custom AI CLI",
371        long_about = "Adds a custom CLI to your global config (~/.config/git-paw/config.toml). \
372                      The command can be an absolute path or a binary name on PATH.\n\n\
373                      Examples:\n  \
374                      git paw add-cli my-agent /usr/local/bin/my-agent\n  \
375                      git paw add-cli my-agent my-agent --display-name \"My Agent\""
376    )]
377    AddCli {
378        /// Name to register the CLI as.
379        #[arg(help = "Name to register the CLI as")]
380        name: String,
381
382        /// Command or path to the CLI binary.
383        #[arg(help = "Command or path to the CLI binary")]
384        command: String,
385
386        /// Optional display name for the CLI.
387        #[arg(long, help = "Display name shown in prompts")]
388        display_name: Option<String>,
389    },
390
391    /// Unregister a custom AI CLI
392    #[command(
393        about = "Unregister a custom AI CLI",
394        long_about = "Removes a custom CLI from your global config. Only custom CLIs can be \
395                      removed — auto-detected CLIs cannot.\n\n\
396                      Example:\n  git paw remove-cli my-agent"
397    )]
398    RemoveCli {
399        /// Name of the custom CLI to remove.
400        #[arg(help = "Name of the custom CLI to remove")]
401        name: String,
402    },
403
404    /// Initialize .git-paw/ directory and configuration
405    #[command(
406        about = "Initialize .git-paw/ directory and configuration",
407        long_about = "Creates the .git-paw/ directory with a default config and sets up \
408                      .gitignore for logs.\n\n\
409                      Examples:\n  git paw init"
410    )]
411    Init,
412
413    /// Internal: run the broker and dashboard in pane 0
414    #[command(
415        hide = true,
416        name = "__dashboard",
417        about = "Internal: run the broker and dashboard in pane 0",
418        long_about = "Internal subcommand used by git-paw to run the broker and dashboard TUI \
419                      in pane 0 of a tmux session. Not intended for direct invocation."
420    )]
421    Dashboard,
422
423    /// View captured session logs
424    #[command(
425        about = "View captured session logs",
426        long_about = "Reads session logs captured by pipe-pane. By default, strips ANSI codes \
427                      for clean output. Use --color to view with colors via less -R.\n\n\
428                      Examples:\n  \
429                      git paw replay --list\n  \
430                      git paw replay feat/add-auth\n  \
431                      git paw replay feat/add-auth --color\n  \
432                      git paw replay feat/add-auth --session paw-myproject"
433    )]
434    Replay {
435        /// Branch name to replay (fuzzy-matched against log filenames).
436        #[arg(required_unless_present = "list", help = "Branch to replay")]
437        branch: Option<String>,
438
439        /// List available log sessions and branches.
440        #[arg(long, help = "List available log sessions and branches")]
441        list: bool,
442
443        /// Display with ANSI colors via less -R.
444        #[arg(long, help = "Display with colors via less -R")]
445        color: bool,
446
447        /// Session name to replay from (defaults to most recent).
448        #[arg(long, help = "Session to replay from (defaults to most recent)")]
449        session: Option<String>,
450    },
451
452    /// Report manually-approved command patterns for a session
453    #[command(
454        about = "Report manually-approved command patterns for a session",
455        long_about = "Lists the command patterns you manually approved during a session — the \
456                      prompts the auto-approve preset did NOT match — sorted by how often each \
457                      was approved. Each row carries a SUGGEST hint for where the pattern might \
458                      be promoted: the project-local allowlist (project-specific scripts/paths) \
459                      or the bundled dev-allowlist preset (general commands like `make <target>`). \
460                      The suggestion is a hint, not a rule.\n\n\
461                      Reads `.git-paw/sessions/<session>.manual-approvals.jsonl`. Defaults to the \
462                      active session; pass --session to target another. Recording is controlled by \
463                      `[supervisor] manual_approvals_log` (default on).\n\n\
464                      Examples:\n  \
465                      git paw approvals\n  \
466                      git paw approvals --json\n  \
467                      git paw approvals --session paw-myproject\n  \
468                      git paw approvals --limit 5"
469    )]
470    Approvals {
471        /// Session to read approvals from (defaults to the active session).
472        #[arg(long, help = "Session to read from (defaults to the active session)")]
473        session: Option<String>,
474
475        /// Cap the output to the top N patterns by count.
476        #[arg(long, help = "Show at most N patterns (top N by count)")]
477        limit: Option<usize>,
478
479        /// Emit machine-readable JSON instead of the text table.
480        #[arg(long, help = "Emit machine-readable JSON")]
481        json: bool,
482    },
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use clap::Parser;
489
490    /// Helper: parse args as if running `git-paw <args>`.
491    fn parse(args: &[&str]) -> Cli {
492        let mut full = vec!["git-paw"];
493        full.extend(args);
494        Cli::try_parse_from(full).expect("failed to parse")
495    }
496
497    // -- Default subcommand --
498
499    #[test]
500    fn no_args_defaults_to_none_command() {
501        let cli = parse(&[]);
502        assert!(
503            cli.command.is_none(),
504            "no args should yield None (handled as Start in main)"
505        );
506    }
507
508    // -- Start subcommand --
509
510    #[test]
511    fn start_with_no_flags() {
512        let cli = parse(&["start"]);
513        match cli.command.unwrap() {
514            Command::Start {
515                cli,
516                branches,
517                from_all_specs,
518                specs,
519                specs_format,
520                dry_run,
521                preset,
522                supervisor,
523                no_supervisor,
524                force,
525                no_rebase,
526            } => {
527                assert!(cli.is_none());
528                assert!(branches.is_none());
529                assert!(!from_all_specs);
530                assert!(specs.is_none());
531                assert!(specs_format.is_none());
532                assert!(!dry_run);
533                assert!(preset.is_none());
534                assert!(!supervisor);
535                assert!(!no_supervisor);
536                assert!(!force);
537                assert!(!no_rebase);
538            }
539            other => panic!("expected Start, got {other:?}"),
540        }
541    }
542
543    #[test]
544    fn start_with_cli_flag() {
545        let cli = parse(&["start", "--cli", "claude"]);
546        match cli.command.unwrap() {
547            Command::Start { cli, .. } => assert_eq!(cli.as_deref(), Some("claude")),
548            other => panic!("expected Start, got {other:?}"),
549        }
550    }
551
552    #[test]
553    fn start_with_from_all_specs_sets_flag_and_leaves_specs_unset() {
554        let cli = parse(&["start", "--from-all-specs"]);
555        match cli.command.unwrap() {
556            Command::Start {
557                from_all_specs,
558                specs,
559                ..
560            } => {
561                assert!(from_all_specs);
562                assert!(specs.is_none());
563            }
564            other => panic!("expected Start, got {other:?}"),
565        }
566    }
567
568    #[test]
569    fn start_with_from_specs_alias_parses_identically_to_from_all_specs() {
570        let alias_args = parse(&["start", "--from-specs"]);
571        let canonical_args = parse(&["start", "--from-all-specs"]);
572        match (alias_args.command.unwrap(), canonical_args.command.unwrap()) {
573            (
574                Command::Start {
575                    from_all_specs: a_all,
576                    specs: a_specs,
577                    supervisor: a_sup,
578                    ..
579                },
580                Command::Start {
581                    from_all_specs: c_all,
582                    specs: c_specs,
583                    supervisor: c_sup,
584                    ..
585                },
586            ) => {
587                assert_eq!(a_all, c_all);
588                assert_eq!(a_specs, c_specs);
589                assert_eq!(a_sup, c_sup);
590                assert!(a_all);
591            }
592            other => panic!("expected two Start variants, got {other:?}"),
593        }
594    }
595
596    #[test]
597    fn start_with_bare_specs_yields_empty_vec_picker_mode() {
598        let cli = parse(&["start", "--specs"]);
599        match cli.command.unwrap() {
600            Command::Start {
601                from_all_specs,
602                specs,
603                ..
604            } => {
605                assert!(!from_all_specs);
606                assert_eq!(specs, Some(Vec::<String>::new()));
607            }
608            other => panic!("expected Start, got {other:?}"),
609        }
610    }
611
612    #[test]
613    fn start_with_specs_single_name() {
614        let cli = parse(&["start", "--specs", "add-auth"]);
615        match cli.command.unwrap() {
616            Command::Start { specs, .. } => {
617                assert_eq!(specs, Some(vec!["add-auth".to_string()]));
618            }
619            other => panic!("expected Start, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn start_with_specs_two_comma_separated_names() {
625        let cli = parse(&["start", "--specs", "add-auth,fix-session"]);
626        match cli.command.unwrap() {
627            Command::Start { specs, .. } => {
628                assert_eq!(
629                    specs,
630                    Some(vec!["add-auth".to_string(), "fix-session".to_string()])
631                );
632            }
633            other => panic!("expected Start, got {other:?}"),
634        }
635    }
636
637    #[test]
638    fn start_with_specs_three_comma_separated_names() {
639        let cli = parse(&["start", "--specs", "add-auth,fix-session,add-logging"]);
640        match cli.command.unwrap() {
641            Command::Start { specs, .. } => {
642                assert_eq!(
643                    specs,
644                    Some(vec![
645                        "add-auth".to_string(),
646                        "fix-session".to_string(),
647                        "add-logging".to_string(),
648                    ])
649                );
650            }
651            other => panic!("expected Start, got {other:?}"),
652        }
653    }
654
655    #[test]
656    fn start_with_from_all_specs_and_specs_is_rejected() {
657        let result = Cli::try_parse_from([
658            "git-paw",
659            "start",
660            "--from-all-specs",
661            "--specs",
662            "add-auth",
663        ]);
664        assert!(result.is_err());
665        let err = result.unwrap_err().to_string();
666        assert!(err.contains("--from-all-specs"), "got: {err}");
667        assert!(err.contains("--specs"), "got: {err}");
668    }
669
670    #[test]
671    fn start_with_from_specs_alias_and_specs_is_rejected() {
672        let result =
673            Cli::try_parse_from(["git-paw", "start", "--from-specs", "--specs", "add-auth"]);
674        assert!(result.is_err());
675    }
676
677    #[test]
678    fn start_with_from_all_specs_and_supervisor_sets_both_flags() {
679        let cli = parse(&["start", "--from-all-specs", "--supervisor"]);
680        match cli.command.unwrap() {
681            Command::Start {
682                from_all_specs,
683                specs,
684                supervisor,
685                ..
686            } => {
687                assert!(from_all_specs);
688                assert!(supervisor);
689                assert!(specs.is_none());
690            }
691            other => panic!("expected Start, got {other:?}"),
692        }
693    }
694
695    #[test]
696    fn start_with_supervisor_only_leaves_spec_mode_unset() {
697        let cli = parse(&["start", "--supervisor"]);
698        match cli.command.unwrap() {
699            Command::Start {
700                from_all_specs,
701                specs,
702                supervisor,
703                ..
704            } => {
705                assert!(!from_all_specs);
706                assert!(specs.is_none());
707                assert!(supervisor);
708            }
709            other => panic!("expected Start, got {other:?}"),
710        }
711    }
712
713    #[test]
714    fn start_help_contains_from_all_specs_and_specs_but_not_alias() {
715        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
716        let err = result.unwrap_err();
717        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
718        let help = err.to_string();
719        assert!(
720            help.contains("--from-all-specs"),
721            "start --help should contain --from-all-specs; got: {help}"
722        );
723        assert!(
724            help.contains("--specs"),
725            "start --help should contain --specs; got: {help}"
726        );
727        assert!(
728            !help.contains("--from-specs"),
729            "start --help should NOT contain hidden alias --from-specs; got: {help}"
730        );
731    }
732
733    #[test]
734    fn start_with_branches_flag_comma_separated() {
735        let cli = parse(&["start", "--branches", "feat/a,feat/b,fix/c"]);
736        match cli.command.unwrap() {
737            Command::Start { branches, .. } => {
738                let b = branches.expect("branches should be set");
739                assert_eq!(b, vec!["feat/a", "feat/b", "fix/c"]);
740            }
741            other => panic!("expected Start, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn start_with_dry_run() {
747        let cli = parse(&["start", "--dry-run"]);
748        match cli.command.unwrap() {
749            Command::Start { dry_run, .. } => assert!(dry_run),
750            other => panic!("expected Start, got {other:?}"),
751        }
752    }
753
754    #[test]
755    fn start_with_preset() {
756        let cli = parse(&["start", "--preset", "backend"]);
757        match cli.command.unwrap() {
758            Command::Start { preset, .. } => assert_eq!(preset.as_deref(), Some("backend")),
759            other => panic!("expected Start, got {other:?}"),
760        }
761    }
762
763    #[test]
764    fn start_with_supervisor_flag() {
765        let cli = parse(&["start", "--supervisor"]);
766        match cli.command.unwrap() {
767            Command::Start { supervisor, .. } => assert!(supervisor),
768            other => panic!("expected Start, got {other:?}"),
769        }
770    }
771
772    #[test]
773    fn start_without_supervisor_defaults_false() {
774        let cli = parse(&["start", "--cli", "claude"]);
775        match cli.command.unwrap() {
776            Command::Start { supervisor, .. } => assert!(!supervisor),
777            other => panic!("expected Start, got {other:?}"),
778        }
779    }
780
781    #[test]
782    fn start_with_supervisor_and_other_flags() {
783        let cli = parse(&[
784            "start",
785            "--supervisor",
786            "--cli",
787            "claude",
788            "--branches",
789            "feat/a,feat/b",
790        ]);
791        match cli.command.unwrap() {
792            Command::Start {
793                supervisor,
794                cli,
795                branches,
796                ..
797            } => {
798                assert!(supervisor);
799                assert_eq!(cli.as_deref(), Some("claude"));
800                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
801            }
802            other => panic!("expected Start, got {other:?}"),
803        }
804    }
805
806    // -- --specs-format flag --
807
808    #[test]
809    fn start_with_specs_format_speckit() {
810        let cli = parse(&["start", "--from-specs", "--specs-format", "speckit"]);
811        match cli.command.unwrap() {
812            Command::Start { specs_format, .. } => {
813                assert_eq!(specs_format, Some(SpecsFormat::Speckit));
814            }
815            other => panic!("expected Start, got {other:?}"),
816        }
817    }
818
819    #[test]
820    fn start_with_specs_format_openspec() {
821        let cli = parse(&["start", "--from-specs", "--specs-format", "openspec"]);
822        match cli.command.unwrap() {
823            Command::Start { specs_format, .. } => {
824                assert_eq!(specs_format, Some(SpecsFormat::Openspec));
825            }
826            other => panic!("expected Start, got {other:?}"),
827        }
828    }
829
830    #[test]
831    fn start_with_specs_format_markdown() {
832        let cli = parse(&["start", "--from-specs", "--specs-format", "markdown"]);
833        match cli.command.unwrap() {
834            Command::Start { specs_format, .. } => {
835                assert_eq!(specs_format, Some(SpecsFormat::Markdown));
836            }
837            other => panic!("expected Start, got {other:?}"),
838        }
839    }
840
841    #[test]
842    fn start_rejects_unknown_specs_format() {
843        let result = Cli::try_parse_from([
844            "git-paw",
845            "start",
846            "--from-specs",
847            "--specs-format",
848            "unknown-value",
849        ]);
850        assert!(result.is_err(), "unknown value should be rejected");
851        let err = result.unwrap_err().to_string();
852        assert!(
853            err.contains("openspec") && err.contains("markdown") && err.contains("speckit"),
854            "error should list all three valid values, got: {err}"
855        );
856    }
857
858    #[test]
859    fn specs_format_as_str_matches_backend_names() {
860        assert_eq!(SpecsFormat::Openspec.as_str(), "openspec");
861        assert_eq!(SpecsFormat::Markdown.as_str(), "markdown");
862        assert_eq!(SpecsFormat::Speckit.as_str(), "speckit");
863    }
864
865    #[test]
866    fn start_help_shows_specs_format_flag() {
867        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
868        assert!(result.is_err());
869        let err = result.unwrap_err();
870        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
871        let help = err.to_string();
872        assert!(
873            help.contains("--specs-format"),
874            "start --help should contain --specs-format"
875        );
876    }
877
878    #[test]
879    fn start_help_shows_supervisor_flag() {
880        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
881        assert!(result.is_err());
882        let err = result.unwrap_err();
883        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
884        let help = err.to_string();
885        assert!(
886            help.contains("--supervisor"),
887            "start --help should contain --supervisor"
888        );
889    }
890
891    // -- --no-supervisor flag --
892
893    #[test]
894    fn start_with_no_supervisor_flag() {
895        let cli = parse(&["start", "--no-supervisor"]);
896        match cli.command.unwrap() {
897            Command::Start {
898                supervisor,
899                no_supervisor,
900                ..
901            } => {
902                assert!(no_supervisor);
903                assert!(!supervisor);
904            }
905            other => panic!("expected Start, got {other:?}"),
906        }
907    }
908
909    #[test]
910    fn start_without_flags_leaves_no_supervisor_false() {
911        let cli = parse(&["start"]);
912        match cli.command.unwrap() {
913            Command::Start {
914                supervisor,
915                no_supervisor,
916                ..
917            } => {
918                assert!(!no_supervisor);
919                assert!(!supervisor);
920            }
921            other => panic!("expected Start, got {other:?}"),
922        }
923    }
924
925    #[test]
926    fn start_with_supervisor_and_no_supervisor_is_rejected() {
927        let result = Cli::try_parse_from(["git-paw", "start", "--supervisor", "--no-supervisor"]);
928        assert!(
929            result.is_err(),
930            "--supervisor + --no-supervisor must be rejected by clap"
931        );
932        let err = result.unwrap_err();
933        let msg = err.to_string();
934        assert!(
935            msg.contains("--supervisor") && msg.contains("--no-supervisor"),
936            "error should mention both flags, got: {msg}"
937        );
938    }
939
940    #[test]
941    fn start_with_no_supervisor_and_supervisor_reversed_is_also_rejected() {
942        // clap's conflicts_with is bidirectional; order of flags shouldn't matter.
943        let result = Cli::try_parse_from(["git-paw", "start", "--no-supervisor", "--supervisor"]);
944        assert!(result.is_err());
945    }
946
947    #[test]
948    fn start_no_supervisor_combines_with_other_flags() {
949        let cli = parse(&[
950            "start",
951            "--no-supervisor",
952            "--cli",
953            "claude",
954            "--branches",
955            "feat/a,feat/b",
956        ]);
957        match cli.command.unwrap() {
958            Command::Start {
959                no_supervisor,
960                cli,
961                branches,
962                ..
963            } => {
964                assert!(no_supervisor);
965                assert_eq!(cli.as_deref(), Some("claude"));
966                assert_eq!(branches.unwrap(), vec!["feat/a", "feat/b"]);
967            }
968            other => panic!("expected Start, got {other:?}"),
969        }
970    }
971
972    #[test]
973    fn start_help_shows_no_supervisor_flag() {
974        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
975        assert!(result.is_err());
976        let err = result.unwrap_err();
977        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
978        let help = err.to_string();
979        assert!(
980            help.contains("--no-supervisor"),
981            "start --help should contain --no-supervisor, got: {help}"
982        );
983    }
984
985    #[test]
986    fn start_with_all_flags() {
987        let cli = parse(&[
988            "start",
989            "--cli",
990            "gemini",
991            "--branches",
992            "a,b",
993            "--dry-run",
994            "--preset",
995            "dev",
996        ]);
997        match cli.command.unwrap() {
998            Command::Start {
999                cli,
1000                branches,
1001                dry_run,
1002                preset,
1003                ..
1004            } => {
1005                assert_eq!(cli.as_deref(), Some("gemini"));
1006                assert_eq!(branches.unwrap(), vec!["a", "b"]);
1007                assert!(dry_run);
1008                assert_eq!(preset.as_deref(), Some("dev"));
1009            }
1010            other => panic!("expected Start, got {other:?}"),
1011        }
1012    }
1013
1014    // -- Add subcommand --
1015
1016    #[test]
1017    fn add_with_branch_only() {
1018        let cli = parse(&["add", "feat/new"]);
1019        match cli.command.unwrap() {
1020            Command::Add {
1021                branch,
1022                cli,
1023                from_spec,
1024            } => {
1025                assert_eq!(branch.as_deref(), Some("feat/new"));
1026                assert!(cli.is_none());
1027                assert!(from_spec.is_none());
1028            }
1029            other => panic!("expected Add, got {other:?}"),
1030        }
1031    }
1032
1033    #[test]
1034    fn add_with_branch_and_cli() {
1035        let cli = parse(&["add", "feat/x", "--cli", "codex"]);
1036        match cli.command.unwrap() {
1037            Command::Add { branch, cli, .. } => {
1038                assert_eq!(branch.as_deref(), Some("feat/x"));
1039                assert_eq!(cli.as_deref(), Some("codex"));
1040            }
1041            other => panic!("expected Add, got {other:?}"),
1042        }
1043    }
1044
1045    #[test]
1046    fn add_with_from_spec_only_needs_no_branch() {
1047        let cli = parse(&["add", "--from-spec", "add-export"]);
1048        match cli.command.unwrap() {
1049            Command::Add {
1050                branch, from_spec, ..
1051            } => {
1052                assert!(branch.is_none());
1053                assert_eq!(from_spec.as_deref(), Some("add-export"));
1054            }
1055            other => panic!("expected Add, got {other:?}"),
1056        }
1057    }
1058
1059    #[test]
1060    fn add_with_no_branch_and_no_from_spec_is_rejected() {
1061        let result = Cli::try_parse_from(["git-paw", "add"]);
1062        assert!(
1063            result.is_err(),
1064            "add requires either a branch or --from-spec"
1065        );
1066    }
1067
1068    #[test]
1069    fn add_with_branch_and_from_spec_is_rejected() {
1070        let result = Cli::try_parse_from(["git-paw", "add", "feat/x", "--from-spec", "change"]);
1071        assert!(
1072            result.is_err(),
1073            "branch and --from-spec are mutually exclusive"
1074        );
1075    }
1076
1077    #[test]
1078    fn add_help_lists_flags_and_examples() {
1079        let result = Cli::try_parse_from(["git-paw", "add", "--help"]);
1080        let err = result.unwrap_err();
1081        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1082        let help = err.to_string();
1083        assert!(help.contains("--cli"), "got: {help}");
1084        assert!(help.contains("--from-spec"), "got: {help}");
1085        assert!(
1086            help.contains("git paw add feat/api --cli codex"),
1087            "add --help should include copy-pasteable examples; got: {help}"
1088        );
1089    }
1090
1091    // -- Remove subcommand --
1092
1093    #[test]
1094    fn remove_with_branch_only() {
1095        let cli = parse(&["remove", "feat/done"]);
1096        match cli.command.unwrap() {
1097            Command::Remove {
1098                branch,
1099                keep_worktree,
1100                force,
1101            } => {
1102                assert_eq!(branch, "feat/done");
1103                assert!(!keep_worktree);
1104                assert!(!force);
1105            }
1106            other => panic!("expected Remove, got {other:?}"),
1107        }
1108    }
1109
1110    #[test]
1111    fn remove_with_keep_worktree_and_force() {
1112        let cli = parse(&["remove", "feat/x", "--keep-worktree", "--force"]);
1113        match cli.command.unwrap() {
1114            Command::Remove {
1115                keep_worktree,
1116                force,
1117                ..
1118            } => {
1119                assert!(keep_worktree);
1120                assert!(force);
1121            }
1122            other => panic!("expected Remove, got {other:?}"),
1123        }
1124    }
1125
1126    #[test]
1127    fn remove_without_branch_is_rejected() {
1128        let result = Cli::try_parse_from(["git-paw", "remove"]);
1129        assert!(result.is_err(), "remove requires a branch");
1130    }
1131
1132    #[test]
1133    fn remove_help_lists_flags_and_examples() {
1134        let result = Cli::try_parse_from(["git-paw", "remove", "--help"]);
1135        let err = result.unwrap_err();
1136        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1137        let help = err.to_string();
1138        assert!(help.contains("--keep-worktree"), "got: {help}");
1139        assert!(help.contains("--force"), "got: {help}");
1140        assert!(
1141            help.contains("git paw remove feat/wip --force"),
1142            "remove --help should include copy-pasteable examples; got: {help}"
1143        );
1144    }
1145
1146    #[test]
1147    fn root_help_lists_add_and_remove() {
1148        let result = Cli::try_parse_from(["git-paw", "--help"]);
1149        let help = result.unwrap_err().to_string();
1150        assert!(
1151            help.contains("add"),
1152            "root help should list add; got: {help}"
1153        );
1154        assert!(
1155            help.contains("remove"),
1156            "root help should list remove; got: {help}"
1157        );
1158    }
1159
1160    // -- Pause subcommand --
1161
1162    #[test]
1163    fn pause_parses() {
1164        let cli = parse(&["pause"]);
1165        assert!(matches!(cli.command.unwrap(), Command::Pause));
1166    }
1167
1168    #[test]
1169    fn pause_help_mentions_ram_tradeoff() {
1170        let result = Cli::try_parse_from(["git-paw", "pause", "--help"]);
1171        assert!(result.is_err());
1172        let err = result.unwrap_err();
1173        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1174        let help = err.to_string();
1175        assert!(
1176            help.to_lowercase().contains("ram"),
1177            "pause --help should mention RAM tradeoff, got: {help}"
1178        );
1179        assert!(
1180            help.contains("stop"),
1181            "pause --help should cross-reference stop, got: {help}"
1182        );
1183    }
1184
1185    #[test]
1186    fn pause_rejects_unknown_flags() {
1187        let result = Cli::try_parse_from(["git-paw", "pause", "--anything"]);
1188        assert!(result.is_err(), "pause should reject unknown flags");
1189    }
1190
1191    #[test]
1192    fn root_help_lists_pause() {
1193        let result = Cli::try_parse_from(["git-paw", "--help"]);
1194        assert!(result.is_err());
1195        let err = result.unwrap_err();
1196        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1197        let help = err.to_string();
1198        assert!(
1199            help.contains("pause"),
1200            "root --help should list pause subcommand, got: {help}"
1201        );
1202        // Quick-start block should also reference pause.
1203        assert!(
1204            help.contains("git paw pause"),
1205            "after_help quick-start should mention `git paw pause`"
1206        );
1207    }
1208
1209    // -- Stop subcommand --
1210
1211    #[test]
1212    fn stop_parses() {
1213        let cli = parse(&["stop"]);
1214        assert!(matches!(
1215            cli.command.unwrap(),
1216            Command::Stop { force: false }
1217        ));
1218    }
1219
1220    #[test]
1221    fn stop_without_force() {
1222        let cli = parse(&["stop"]);
1223        match cli.command.unwrap() {
1224            Command::Stop { force } => assert!(!force),
1225            other => panic!("expected Stop, got {other:?}"),
1226        }
1227    }
1228
1229    #[test]
1230    fn stop_with_force() {
1231        let cli = parse(&["stop", "--force"]);
1232        match cli.command.unwrap() {
1233            Command::Stop { force } => assert!(force),
1234            other => panic!("expected Stop, got {other:?}"),
1235        }
1236    }
1237
1238    #[test]
1239    fn stop_help_mentions_pause_and_purge() {
1240        let result = Cli::try_parse_from(["git-paw", "stop", "--help"]);
1241        assert!(result.is_err());
1242        let err = result.unwrap_err();
1243        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1244        let help = err.to_string();
1245        assert!(
1246            help.contains("pause"),
1247            "stop --help should reference pause, got: {help}"
1248        );
1249        assert!(
1250            help.contains("purge"),
1251            "stop --help should reference purge, got: {help}"
1252        );
1253        assert!(
1254            help.contains("--force"),
1255            "stop --help should list --force, got: {help}"
1256        );
1257    }
1258
1259    // -- Purge subcommand --
1260
1261    #[test]
1262    fn purge_without_force() {
1263        let cli = parse(&["purge"]);
1264        match cli.command.unwrap() {
1265            Command::Purge { force, stale } => {
1266                assert!(!force);
1267                assert!(!stale);
1268            }
1269            other => panic!("expected Purge, got {other:?}"),
1270        }
1271    }
1272
1273    #[test]
1274    fn purge_with_force() {
1275        let cli = parse(&["purge", "--force"]);
1276        match cli.command.unwrap() {
1277            Command::Purge { force, stale } => {
1278                assert!(force);
1279                assert!(!stale);
1280            }
1281            other => panic!("expected Purge, got {other:?}"),
1282        }
1283    }
1284
1285    #[test]
1286    fn purge_with_stale() {
1287        let cli = parse(&["purge", "--stale"]);
1288        match cli.command.unwrap() {
1289            Command::Purge { force, stale } => {
1290                assert!(!force);
1291                assert!(stale);
1292            }
1293            other => panic!("expected Purge, got {other:?}"),
1294        }
1295    }
1296
1297    #[test]
1298    fn purge_with_stale_and_force() {
1299        let cli = parse(&["purge", "--stale", "--force"]);
1300        match cli.command.unwrap() {
1301            Command::Purge { force, stale } => {
1302                assert!(force);
1303                assert!(stale);
1304            }
1305            other => panic!("expected Purge, got {other:?}"),
1306        }
1307    }
1308
1309    // -- Status subcommand --
1310
1311    #[test]
1312    fn status_parses() {
1313        let cli = parse(&["status"]);
1314        match cli.command.unwrap() {
1315            Command::Status { json } => assert!(!json),
1316            other => panic!("expected Status, got {other:?}"),
1317        }
1318    }
1319
1320    #[test]
1321    fn status_with_json() {
1322        let cli = parse(&["status", "--json"]);
1323        match cli.command.unwrap() {
1324            Command::Status { json } => assert!(json),
1325            other => panic!("expected Status, got {other:?}"),
1326        }
1327    }
1328
1329    // -- List-CLIs subcommand --
1330
1331    #[test]
1332    fn list_clis_parses() {
1333        let cli = parse(&["list-clis"]);
1334        assert!(matches!(cli.command.unwrap(), Command::ListClis));
1335    }
1336
1337    // -- Add-CLI subcommand --
1338
1339    #[test]
1340    fn add_cli_with_required_args() {
1341        let cli = parse(&["add-cli", "my-agent", "/usr/local/bin/my-agent"]);
1342        match cli.command.unwrap() {
1343            Command::AddCli {
1344                name,
1345                command,
1346                display_name,
1347            } => {
1348                assert_eq!(name, "my-agent");
1349                assert_eq!(command, "/usr/local/bin/my-agent");
1350                assert!(display_name.is_none());
1351            }
1352            other => panic!("expected AddCli, got {other:?}"),
1353        }
1354    }
1355
1356    #[test]
1357    fn add_cli_with_display_name() {
1358        let cli = parse(&[
1359            "add-cli",
1360            "my-agent",
1361            "my-agent",
1362            "--display-name",
1363            "My Agent",
1364        ]);
1365        match cli.command.unwrap() {
1366            Command::AddCli {
1367                name,
1368                command,
1369                display_name,
1370            } => {
1371                assert_eq!(name, "my-agent");
1372                assert_eq!(command, "my-agent");
1373                assert_eq!(display_name.as_deref(), Some("My Agent"));
1374            }
1375            other => panic!("expected AddCli, got {other:?}"),
1376        }
1377    }
1378
1379    // -- Remove-CLI subcommand --
1380
1381    #[test]
1382    fn remove_cli_parses() {
1383        let cli = parse(&["remove-cli", "my-agent"]);
1384        match cli.command.unwrap() {
1385            Command::RemoveCli { name } => assert_eq!(name, "my-agent"),
1386            other => panic!("expected RemoveCli, got {other:?}"),
1387        }
1388    }
1389
1390    // -- Help text quality --
1391
1392    #[test]
1393    fn version_flag_is_accepted() {
1394        let result = Cli::try_parse_from(["git-paw", "--version"]);
1395        // clap returns Err(DisplayVersion) for --version, which is expected
1396        assert!(result.is_err());
1397        let err = result.unwrap_err();
1398        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
1399    }
1400
1401    #[test]
1402    fn help_flag_is_accepted() {
1403        let result = Cli::try_parse_from(["git-paw", "--help"]);
1404        assert!(result.is_err());
1405        let err = result.unwrap_err();
1406        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1407    }
1408
1409    // --- Gap #6: init_parses ---
1410
1411    #[test]
1412    fn init_parses() {
1413        let cli = parse(&["init"]);
1414        assert!(matches!(cli.command.unwrap(), Command::Init));
1415    }
1416
1417    // --- Gap #7: init_help_text ---
1418
1419    #[test]
1420    fn init_help_text() {
1421        let result = Cli::try_parse_from(["git-paw", "init", "--help"]);
1422        assert!(result.is_err());
1423        let err = result.unwrap_err();
1424        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1425    }
1426
1427    #[test]
1428    fn unknown_subcommand_is_rejected() {
1429        let result = Cli::try_parse_from(["git-paw", "unknown-command"]);
1430        assert!(result.is_err());
1431    }
1432
1433    #[test]
1434    fn add_cli_missing_required_args_is_rejected() {
1435        let result = Cli::try_parse_from(["git-paw", "add-cli"]);
1436        assert!(result.is_err());
1437    }
1438
1439    // -- Replay subcommand --
1440
1441    #[test]
1442    fn replay_with_branch() {
1443        let cli = parse(&["replay", "feat/add-auth"]);
1444        match cli.command.unwrap() {
1445            Command::Replay {
1446                branch,
1447                list,
1448                color,
1449                session,
1450            } => {
1451                assert_eq!(branch.as_deref(), Some("feat/add-auth"));
1452                assert!(!list);
1453                assert!(!color);
1454                assert!(session.is_none());
1455            }
1456            other => panic!("expected Replay, got {other:?}"),
1457        }
1458    }
1459
1460    #[test]
1461    fn replay_with_list() {
1462        let cli = parse(&["replay", "--list"]);
1463        match cli.command.unwrap() {
1464            Command::Replay { branch, list, .. } => {
1465                assert!(list);
1466                assert!(branch.is_none());
1467            }
1468            other => panic!("expected Replay, got {other:?}"),
1469        }
1470    }
1471
1472    #[test]
1473    fn replay_with_color() {
1474        let cli = parse(&["replay", "feat/add-auth", "--color"]);
1475        match cli.command.unwrap() {
1476            Command::Replay { color, .. } => assert!(color),
1477            other => panic!("expected Replay, got {other:?}"),
1478        }
1479    }
1480
1481    #[test]
1482    fn replay_with_session() {
1483        let cli = parse(&["replay", "feat/add-auth", "--session", "paw-myproject"]);
1484        match cli.command.unwrap() {
1485            Command::Replay { session, .. } => {
1486                assert_eq!(session.as_deref(), Some("paw-myproject"));
1487            }
1488            other => panic!("expected Replay, got {other:?}"),
1489        }
1490    }
1491
1492    #[test]
1493    fn replay_no_args_fails() {
1494        let result = Cli::try_parse_from(["git-paw", "replay"]);
1495        assert!(result.is_err());
1496    }
1497
1498    #[test]
1499    fn replay_help_text() {
1500        let result = Cli::try_parse_from(["git-paw", "replay", "--help"]);
1501        assert!(result.is_err());
1502        let err = result.unwrap_err();
1503        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1504        let help = err.to_string();
1505        assert!(help.contains("--list"));
1506        assert!(help.contains("--color"));
1507        assert!(help.contains("--session"));
1508    }
1509
1510    #[test]
1511    fn help_shows_replay_subcommand() {
1512        let result = Cli::try_parse_from(["git-paw", "--help"]);
1513        let err = result.unwrap_err();
1514        let help = err.to_string();
1515        assert!(
1516            help.contains("replay"),
1517            "help should list the replay subcommand"
1518        );
1519    }
1520
1521    // -- __dashboard subcommand --
1522
1523    #[test]
1524    fn dashboard_parses() {
1525        let cli = parse(&["__dashboard"]);
1526        assert!(matches!(cli.command.unwrap(), Command::Dashboard));
1527    }
1528
1529    // -- --no-rebase flag --
1530
1531    #[test]
1532    fn start_with_no_rebase_flag_sets_no_rebase_true() {
1533        let cli = parse(&["start", "--no-rebase"]);
1534        match cli.command.unwrap() {
1535            Command::Start { no_rebase, .. } => assert!(no_rebase),
1536            other => panic!("expected Start, got {other:?}"),
1537        }
1538    }
1539
1540    #[test]
1541    fn start_without_no_rebase_defaults_to_false() {
1542        let cli = parse(&["start"]);
1543        match cli.command.unwrap() {
1544            Command::Start { no_rebase, .. } => assert!(!no_rebase),
1545            other => panic!("expected Start, got {other:?}"),
1546        }
1547    }
1548
1549    #[test]
1550    fn start_no_rebase_combines_with_supervisor() {
1551        let cli = parse(&["start", "--no-rebase", "--supervisor"]);
1552        match cli.command.unwrap() {
1553            Command::Start {
1554                no_rebase,
1555                supervisor,
1556                ..
1557            } => {
1558                assert!(no_rebase);
1559                assert!(supervisor);
1560            }
1561            other => panic!("expected Start, got {other:?}"),
1562        }
1563    }
1564
1565    #[test]
1566    fn start_help_shows_no_rebase_flag() {
1567        let result = Cli::try_parse_from(["git-paw", "start", "--help"]);
1568        assert!(result.is_err());
1569        let err = result.unwrap_err();
1570        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1571        let help = err.to_string();
1572        assert!(
1573            help.contains("--no-rebase"),
1574            "start --help should contain --no-rebase, got: {help}"
1575        );
1576    }
1577
1578    // -- Approvals subcommand --
1579
1580    #[test]
1581    fn approvals_parses_with_no_flags() {
1582        let cli = parse(&["approvals"]);
1583        match cli.command.unwrap() {
1584            Command::Approvals {
1585                session,
1586                limit,
1587                json,
1588            } => {
1589                assert!(session.is_none());
1590                assert!(limit.is_none());
1591                assert!(!json);
1592            }
1593            other => panic!("expected Approvals, got {other:?}"),
1594        }
1595    }
1596
1597    #[test]
1598    fn approvals_with_session_limit_and_json() {
1599        let cli = parse(&[
1600            "approvals",
1601            "--session",
1602            "paw-other",
1603            "--limit",
1604            "5",
1605            "--json",
1606        ]);
1607        match cli.command.unwrap() {
1608            Command::Approvals {
1609                session,
1610                limit,
1611                json,
1612            } => {
1613                assert_eq!(session.as_deref(), Some("paw-other"));
1614                assert_eq!(limit, Some(5));
1615                assert!(json);
1616            }
1617            other => panic!("expected Approvals, got {other:?}"),
1618        }
1619    }
1620
1621    #[test]
1622    fn approvals_rejects_non_numeric_limit() {
1623        let result = Cli::try_parse_from(["git-paw", "approvals", "--limit", "lots"]);
1624        assert!(result.is_err());
1625    }
1626
1627    #[test]
1628    fn approvals_help_lists_flags_and_examples() {
1629        let result = Cli::try_parse_from(["git-paw", "approvals", "--help"]);
1630        assert!(result.is_err());
1631        let err = result.unwrap_err();
1632        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
1633        let help = err.to_string();
1634        assert!(help.contains("--session"), "got: {help}");
1635        assert!(help.contains("--limit"), "got: {help}");
1636        assert!(help.contains("--json"), "got: {help}");
1637        assert!(
1638            help.contains("git paw approvals --json"),
1639            "help should include examples, got: {help}"
1640        );
1641    }
1642
1643    #[test]
1644    fn help_shows_approvals_subcommand() {
1645        let result = Cli::try_parse_from(["git-paw", "--help"]);
1646        let err = result.unwrap_err();
1647        let help = err.to_string();
1648        assert!(
1649            help.contains("approvals"),
1650            "root help should list approvals subcommand, got: {help}"
1651        );
1652    }
1653
1654    #[test]
1655    fn dashboard_does_not_appear_in_help() {
1656        let result = Cli::try_parse_from(["git-paw", "--help"]);
1657        let err = result.unwrap_err();
1658        let help = err.to_string();
1659        assert!(
1660            !help.contains("__dashboard"),
1661            "hidden __dashboard subcommand should not appear in help output"
1662        );
1663    }
1664}