Skip to main content

gitee_cli_rs/
cli.rs

1use clap::{Parser, Subcommand};
2use std::ffi::OsString;
3
4#[derive(Parser)]
5#[command(name = "gitee", version, about = "Gitee CLI (gh-like)")]
6pub struct Cli {
7    /// Target repository as owner/name (otherwise resolved from `git remote`).
8    #[arg(long, global = true)]
9    pub repo: Option<String>,
10    /// Git remote to resolve the repo from (default: origin).
11    #[arg(long, global = true)]
12    pub remote: Option<String>,
13    /// Gitee host (default: gitee.com).
14    #[arg(long, global = true, default_value = "gitee.com")]
15    pub host: String,
16    /// Emit JSON. Bare `--json` prints full objects; `--json number,title`
17    /// projects to those fields (arrays project per element).
18    #[arg(long, short = 'j', global = true, num_args = 0..=1, default_missing_value = "")]
19    pub json: Option<String>,
20    /// jq expression applied to --json output (requires --json). Runs AFTER field
21    /// projection. String scalars print unquoted; other results print as JSON;
22    /// multiple results print one per line.
23    #[arg(long, global = true, requires = "json")]
24    pub jq: Option<String>,
25    /// Log HTTP requests and responses to stderr.
26    #[arg(long, global = true)]
27    pub debug: bool,
28    /// Print what would happen, then exit 0. No HTTP call is made. Works on
29    /// mutating verbs (issue/pr create/close/reopen/merge, label create,
30    /// release create/edit/delete, repo delete, gist delete, etc.).
31    #[arg(long, global = true)]
32    pub preview: bool,
33    #[command(subcommand)]
34    pub cmd: Command,
35}
36
37#[derive(Subcommand)]
38pub enum Command {
39    #[command(subcommand)]
40    Pr(PrCmd),
41    #[command(subcommand)]
42    Issue(IssueCmd),
43    #[command(subcommand)]
44    Release(ReleaseCmd),
45    #[command(subcommand)]
46    Milestone(MilestoneCmd),
47    #[command(subcommand)]
48    Repo(RepoCmd),
49    #[command(subcommand)]
50    Label(LabelCmd),
51    #[command(subcommand)]
52    Auth(AuthCmd),
53    #[command(subcommand)]
54    Search(SearchCmd),
55    /// Raw Gitee REST API request (like `gh api`).
56    Api(ApiArgs),
57    #[command(subcommand)]
58    Gist(GistCmd),
59    #[command(subcommand)]
60    Org(OrgCmd),
61    #[command(name = "ssh-key", subcommand)]
62    SshKey(SshKeyCmd),
63    #[command(subcommand)]
64    Collaborator(CollaboratorCmd),
65    #[command(subcommand)]
66    Webhook(WebhookCmd),
67    #[command(subcommand)]
68    Config(ConfigCmd),
69    #[command(subcommand)]
70    Alias(AliasCmd),
71    /// Open the repository in a browser.
72    Browse,
73    /// Cross-repo dashboard of your open issues. PR sections are omitted: Gitee v5 has no user-level pulls endpoint (swagger verified 2026-07-18).
74    Status {
75        #[command(flatten)]
76        limit: LimitArgs,
77    },
78    /// Print a shell completion script (bash, zsh, fish, powershell, elvish).
79    Completions { shell: Option<String> },
80    #[command(subcommand)]
81    Extension(ExtensionCmd),
82    /// Extension or unknown command: exec `gitee-<name>` from PATH.
83    #[command(external_subcommand)]
84    External(Vec<OsString>),
85}
86
87#[derive(clap::Args, Clone, Debug)]
88pub struct ApiArgs {
89    /// API path (e.g. `user`, `/repos/oschina/gitfy/releases`).
90    pub endpoint: String,
91    /// HTTP method (default: GET, or POST when fields or `--input` are given).
92    #[arg(short = 'X', long = "method")]
93    pub method: Option<String>,
94    /// Form field (`key=value`). Repeatable. For urlencoded requests `-F` and
95    /// `-f` behave the same (both pass string pairs); the typed-vs-raw
96    /// distinction only matters for JSON bodies, which this CLI does not build.
97    #[arg(short = 'F', long = "field")]
98    pub fields: Vec<String>,
99    /// Raw form field (`key=value`). Repeatable; same encoding as `-F` here.
100    #[arg(short = 'f', long = "raw-field")]
101    pub raw_fields: Vec<String>,
102    /// Extra request header (`Name: value`). Repeatable.
103    #[arg(short = 'H', long = "header")]
104    pub headers: Vec<String>,
105    /// Raw request body from a file or `-` for stdin (conflicts with `-F`/`-f`).
106    #[arg(long, conflicts_with_all = ["fields", "raw_fields"])]
107    pub input: Option<String>,
108    /// Walk `page`/`per_page` until an empty page and merge JSON arrays.
109    #[arg(long)]
110    pub paginate: bool,
111}
112
113#[derive(clap::Args, Clone, Debug)]
114pub struct ListArgs {
115    #[arg(long)]
116    pub state: Option<String>,
117    #[arg(long, default_value_t = 30)]
118    pub limit: usize,
119}
120
121#[derive(clap::Args, Clone, Debug)]
122pub struct LimitArgs {
123    #[arg(long, default_value_t = 30)]
124    pub limit: usize,
125}
126
127#[derive(clap::Args, Clone, Debug)]
128pub struct CommentArgs {
129    #[arg(long, short = 'm')]
130    pub body: String,
131}
132
133#[derive(Subcommand, Clone)]
134pub enum PrCmd {
135    List {
136        #[command(flatten)]
137        list: ListArgs,
138        #[arg(long)]
139        author: Option<String>,
140    },
141    /// Show open PRs relevant to you: created, assigned, awaiting your test.
142    Status {
143        #[command(flatten)]
144        limit: LimitArgs,
145    },
146    /// Show details of a pull request.
147    View {
148        number: i64,
149        /// Open in a browser instead of printing.
150        #[arg(long)]
151        web: bool,
152        /// Exit 0 when the pull request is merged, 1 when not (no output).
153        #[arg(long)]
154        merged: bool,
155    },
156    /// Show the unified diff of a pull request.
157    Diff {
158        number: i64,
159    },
160    /// List commits in a pull request (git-log style).
161    Commits {
162        number: i64,
163        #[command(flatten)]
164        limit: LimitArgs,
165    },
166    /// Fetch and check out a pull request branch locally.
167    Checkout {
168        number: i64,
169    },
170    /// Create a pull request. --fill derives title/body from commits; without
171    /// --body/--fill, the repo's pull request template prefills the body.
172    Create {
173        #[arg(long)]
174        title: Option<String>,
175        #[arg(long)]
176        body: Option<String>,
177        #[arg(long)]
178        head: Option<String>,
179        #[arg(long)]
180        base: Option<String>,
181        /// Fill title (first commit subject) and body (commit list) from the
182        /// base..head commit range via local git.
183        #[arg(long)]
184        fill: bool,
185        /// Assign a user (repeatable, gh-style).
186        #[arg(long)]
187        assignee: Vec<String>,
188        /// Assign a tester (repeatable).
189        #[arg(long)]
190        tester: Vec<String>,
191        /// Add labels (repeatable; each may be comma-separated).
192        #[arg(long)]
193        label: Vec<String>,
194        /// Milestone by number or exact title (resolved via the milestones API).
195        #[arg(long)]
196        milestone: Option<String>,
197        /// Link an issue (ident, e.g. I1AB2C) that merging this PR closes.
198        #[arg(long = "close-issue")]
199        close_issue: Option<String>,
200        /// Create as a draft pull request (gh-style).
201        #[arg(long)]
202        draft: bool,
203    },
204    /// Edit a pull request's metadata. At least one flag is required.
205    #[command(group = clap::ArgGroup::new("edit_flags").required(true).multiple(true).args(["title", "body", "assignee", "tester", "label", "milestone"]))]
206    Edit {
207        number: i64,
208        #[arg(long)]
209        title: Option<String>,
210        #[arg(long)]
211        body: Option<String>,
212        /// Assign a user (repeatable, gh-style). Sent as `assignees` (names per
213        /// the PR create endpoint; not listed on the PATCH swagger).
214        #[arg(long)]
215        assignee: Vec<String>,
216        /// Assign a tester (repeatable). Sent as `testers` (same caveat as --assignee).
217        #[arg(long)]
218        tester: Vec<String>,
219        /// Set labels (repeatable; each may be comma-separated). Replaces existing labels.
220        #[arg(long)]
221        label: Vec<String>,
222        /// Milestone by number or exact title (resolved via the milestones API).
223        #[arg(long)]
224        milestone: Option<String>,
225    },
226    Merge {
227        number: i64,
228        #[arg(long)]
229        squash: bool,
230        #[arg(long)]
231        rebase: bool,
232        #[arg(long = "no-close-issue")]
233        no_close_issue: bool,
234    },
235    /// Comment on a pull request.
236    #[command(subcommand)]
237    Comment(PrCommentCmd),
238    /// Labels attached to a pull request (not repo-level label definitions).
239    #[command(subcommand)]
240    Label(PrLabelCmd),
241    /// Reviewers (审查人) on a pull request. Non-destructive; coexist with
242    /// whole-set `pr edit --assignee`.
243    #[command(subcommand)]
244    Assignee(PrAssigneeCmd),
245    /// Testers (测试人) on a pull request. Non-destructive; coexist with
246    /// whole-set `pr edit --tester`.
247    #[command(subcommand)]
248    Tester(PrTesterCmd),
249    Approve {
250        number: i64,
251        #[arg(long)]
252        force: bool,
253    },
254    /// Mark a pull request as tested (测试通过). Gitee-specific: approve covers 审查, test covers 测试.
255    Test {
256        number: i64,
257        #[arg(long)]
258        force: bool,
259    },
260    Close {
261        number: i64,
262    },
263    /// Reopen a closed pull request.
264    Reopen {
265        number: i64,
266    },
267    /// Mark a draft pull request as ready for review (gh-style).
268    Ready {
269        number: i64,
270        /// Convert a ready pull request back to draft.
271        #[arg(long)]
272        undo: bool,
273    },
274    Link {
275        number: i64,
276        issue: String,
277    },
278    // pr update-branch omitted: no such endpoint in the v5 swagger (verified 2026-07-18).
279}
280
281#[derive(Subcommand, Clone)]
282pub enum PrCommentCmd {
283    /// Create a comment on a pull request.
284    Create {
285        number: i64,
286        #[command(flatten)]
287        body: CommentArgs,
288        /// File path for a line/diff comment.
289        #[arg(long)]
290        path: Option<String>,
291        /// Gitee diff-line index (not the file line number).
292        #[arg(long)]
293        position: Option<i64>,
294        /// Commit SHA the diff comment applies to.
295        #[arg(long = "commit-id")]
296        commit_id: Option<String>,
297    },
298    /// List comments on a pull request.
299    List {
300        number: i64,
301        /// Filter by type: `diff` (line/positional) or `general` (maps to Gitee
302        /// `diff_comment` / `pr_comment`).
303        #[arg(long = "type", value_parser = ["diff", "general"])]
304        comment_type: Option<String>,
305        #[command(flatten)]
306        limit: LimitArgs,
307    },
308    /// Edit a comment by id, or the current user's latest on a PR via `--last`.
309    Edit {
310        /// Comment id, or PR number when `--last` is set.
311        target: i64,
312        /// Edit the authenticated user's most-recent comment on the PR
313        /// (`target` is the PR number).
314        #[arg(long)]
315        last: bool,
316        #[arg(long, short = 'm')]
317        body: Option<String>,
318    },
319    /// Delete a comment by id, or the current user's latest on a PR via `--last`.
320    Delete {
321        /// Comment id, or PR number when `--last` is set.
322        target: i64,
323        /// Delete the authenticated user's most-recent comment on the PR
324        /// (`target` is the PR number).
325        #[arg(long)]
326        last: bool,
327        /// Skip the confirmation prompt.
328        #[arg(long, short = 'y')]
329        yes: bool,
330    },
331}
332
333
334#[derive(Subcommand, Clone)]
335pub enum SearchCmd {
336    /// Search repositories.
337    Repos {
338        query: String,
339        #[arg(long)]
340        owner: Option<String>,
341        #[arg(long)]
342        language: Option<String>,
343        /// Only include forked repositories.
344        #[arg(long)]
345        fork: bool,
346        #[arg(long)]
347        sort: Option<String>,
348        #[arg(long)]
349        order: Option<String>,
350        #[command(flatten)]
351        limit: LimitArgs,
352    },
353    /// Search issues (globally unless --repo is set).
354    Issues {
355        query: String,
356        #[arg(long)]
357        state: Option<String>,
358        #[arg(long)]
359        author: Option<String>,
360        #[arg(long)]
361        assignee: Option<String>,
362        #[arg(long)]
363        label: Option<String>,
364        #[arg(long)]
365        language: Option<String>,
366        #[arg(long)]
367        sort: Option<String>,
368        #[arg(long)]
369        order: Option<String>,
370        #[command(flatten)]
371        limit: LimitArgs,
372    },
373    /// Search users.
374    Users {
375        query: String,
376        #[arg(long)]
377        sort: Option<String>,
378        #[arg(long)]
379        order: Option<String>,
380        #[command(flatten)]
381        limit: LimitArgs,
382    },
383}
384
385#[derive(Subcommand, Clone)]
386pub enum IssueCmd {
387    List {
388        #[command(flatten)]
389        list: ListArgs,
390        #[arg(long)]
391        assignee: Option<String>,
392    },
393    /// Show open issues relevant to you: created by me, assigned to me.
394    Status {
395        #[command(flatten)]
396        limit: LimitArgs,
397    },
398    /// Show details of an issue.
399    View {
400        number: String,
401        /// Open in a browser instead of printing.
402        #[arg(long)]
403        web: bool,
404    },
405    Create {
406        #[arg(long)]
407        title: Option<String>,
408        #[arg(long)]
409        body: Option<String>,
410        #[arg(long)]
411        assignee: Option<String>,
412        #[arg(long)]
413        labels: Option<String>,
414        /// Milestone by number or exact title (resolved via the milestones API).
415        #[arg(long)]
416        milestone: Option<String>,
417        /// Mark the issue as a security hole (Gitee-specific).
418        #[arg(long)]
419        security_hole: bool,
420    },
421    /// Edit an issue's metadata. At least one flag is required.
422    ///
423    /// State changes use PATCH `/repos/{owner}/issues/{number}` with a JSON
424    /// body `{repo, title, state}` (current title must be echoed). Do not use
425    /// form fields on `/repos/{owner}/{repo}/issues/{number}` — that path
426    /// often returns an opaque `project or enterprise` 404.
427    ///
428    /// **Gitee v5 write limit:** the live issue-update API only accepts
429    /// `open` | `progressing` | `closed`. `rejected` is **not** a valid write
430    /// value (HTTP 400 `state does not have a valid value`). Closing always
431    /// maps to Chinese lifecycle 已完成 — there is no API close-reason for
432    /// 拒绝 / wontfix; use a repo label (e.g. `wontfix`) for that intent.
433    #[command(group = clap::ArgGroup::new("edit_flags").required(true).multiple(true).args(["title", "body", "assignee", "label", "milestone", "security_hole", "state"]))]
434    Edit {
435        number: String,
436        #[arg(long)]
437        title: Option<String>,
438        #[arg(long)]
439        body: Option<String>,
440        #[arg(long)]
441        assignee: Option<String>,
442        /// Set labels (repeatable; each may be comma-separated). Replaces existing labels.
443        #[arg(long)]
444        label: Vec<String>,
445        /// Milestone by number or exact title (resolved via the milestones API).
446        #[arg(long)]
447        milestone: Option<String>,
448        /// Mark the issue as a security hole (Gitee-specific).
449        #[arg(long)]
450        security_hole: bool,
451        /// Lifecycle state write: `open`, `progressing`, or `closed` only.
452        ///
453        /// Gitee v5 rejects `rejected` (HTTP 400). `closed` always surfaces
454        /// as 已完成, not 拒绝 — see command help for the wontfix workaround.
455        #[arg(long, value_parser = ["open", "progressing", "closed"])]
456        state: Option<String>,
457    },
458    Close {
459        number: String,
460    },
461    /// Reopen a closed issue.
462    Reopen {
463        number: String,
464    },
465    Link {
466        number: String,
467        pr: i64,
468    },
469    /// Comment on an issue.
470    #[command(subcommand)]
471    Comment(IssueCommentCmd),
472    /// Labels attached to an issue (not repo-level label definitions).
473    #[command(subcommand)]
474    Label(IssueLabelCmd),
475}
476
477/// Non-destructive membership for labels on an issue. Distinct from whole-set
478/// `issue edit --label` and from top-level `gitee label` (definitions).
479#[derive(Subcommand, Clone)]
480pub enum IssueLabelCmd {
481    /// Add labels without removing existing ones.
482    Add {
483        /// Issue ident (e.g. I1AB2C).
484        number: String,
485        /// Label names to add.
486        #[arg(required = true, num_args = 1..)]
487        labels: Vec<String>,
488    },
489    /// Remove only the named labels.
490    Remove {
491        /// Issue ident (e.g. I1AB2C).
492        number: String,
493        /// Label names to remove.
494        #[arg(required = true, num_args = 1..)]
495        labels: Vec<String>,
496    },
497    /// List labels currently attached to the issue.
498    List {
499        /// Issue ident (e.g. I1AB2C).
500        number: String,
501    },
502}
503
504/// Non-destructive membership for labels on a PR. Distinct from whole-set
505/// `pr edit --label` and from top-level `gitee label` (definitions).
506#[derive(Subcommand, Clone)]
507pub enum PrLabelCmd {
508    /// Add labels without removing existing ones.
509    Add {
510        number: i64,
511        /// Label names to add.
512        #[arg(required = true, num_args = 1..)]
513        labels: Vec<String>,
514    },
515    /// Remove only the named labels.
516    Remove {
517        number: i64,
518        /// Label names to remove.
519        #[arg(required = true, num_args = 1..)]
520        labels: Vec<String>,
521    },
522    /// List labels currently attached to the pull request.
523    List {
524        number: i64,
525    },
526}
527
528/// Non-destructive reviewer (审查人) membership. Distinct from whole-set
529/// `pr edit --assignee`.
530#[derive(Subcommand, Clone)]
531pub enum PrAssigneeCmd {
532    /// Add reviewers without removing existing ones.
533    Add {
534        number: i64,
535        /// User logins to add as reviewers.
536        #[arg(required = true, num_args = 1..)]
537        users: Vec<String>,
538    },
539    /// Remove only the named reviewers.
540    Remove {
541        number: i64,
542        /// User logins to remove.
543        #[arg(required = true, num_args = 1..)]
544        users: Vec<String>,
545    },
546    /// List reviewers and their accept status (审查通过 / pending).
547    List {
548        number: i64,
549    },
550}
551
552/// Non-destructive tester (测试人) membership. Distinct from whole-set
553/// `pr edit --tester`.
554#[derive(Subcommand, Clone)]
555pub enum PrTesterCmd {
556    /// Add testers without removing existing ones.
557    Add {
558        number: i64,
559        /// User logins to add as testers.
560        #[arg(required = true, num_args = 1..)]
561        users: Vec<String>,
562    },
563    /// Remove only the named testers.
564    Remove {
565        number: i64,
566        /// User logins to remove.
567        #[arg(required = true, num_args = 1..)]
568        users: Vec<String>,
569    },
570    /// List testers on the pull request.
571    List {
572        number: i64,
573    },
574}
575
576#[derive(Subcommand, Clone)]
577pub enum IssueCommentCmd {
578    /// Create a comment on an issue.
579    Create {
580        number: String,
581        #[command(flatten)]
582        body: CommentArgs,
583    },
584    /// List comments on an issue.
585    List {
586        number: String,
587        #[command(flatten)]
588        limit: LimitArgs,
589    },
590    /// Edit a comment by id, or the current user's latest on an issue via `--last`.
591    Edit {
592        /// Comment id, or issue ident when `--last` is set.
593        target: String,
594        /// Edit the authenticated user's most-recent comment on the issue
595        /// (`target` is the issue ident).
596        #[arg(long)]
597        last: bool,
598        #[arg(long, short = 'm')]
599        body: Option<String>,
600    },
601    /// Delete a comment by id, or the current user's latest on an issue via `--last`.
602    Delete {
603        /// Comment id, or issue ident when `--last` is set.
604        target: String,
605        /// Delete the authenticated user's most-recent comment on the issue
606        /// (`target` is the issue ident).
607        #[arg(long)]
608        last: bool,
609        /// Skip the confirmation prompt.
610        #[arg(long, short = 'y')]
611        yes: bool,
612    },
613}
614
615
616#[derive(Subcommand, Clone)]
617pub enum GistCmd {
618    List {
619        #[command(flatten)]
620        limit: LimitArgs,
621    },
622    View {
623        id: String,
624        #[arg(long)]
625        raw: bool,
626    },
627    /// Create a gist from one or more files. Use `-` to read from stdin
628    /// (requires `--filename`). The Gitee API requires a description (1–30
629    /// chars); when `--desc` is omitted it defaults to the first file name.
630    Create {
631        #[arg(long)]
632        desc: Option<String>,
633        #[arg(long)]
634        public: bool,
635        #[arg(long)]
636        filename: Option<String>,
637        #[arg(required = true, num_args = 1..)]
638        files: Vec<String>,
639    },
640    Edit {
641        id: String,
642        file: String,
643    },
644    Delete {
645        id: String,
646        #[arg(long, short = 'y')]
647        yes: bool,
648    },
649}
650
651#[derive(Subcommand, Clone)]
652pub enum ReleaseCmd {
653    List {
654        #[command(flatten)]
655        limit: LimitArgs,
656    },
657    View {
658        tag: String,
659        /// Open in a browser instead of printing.
660        #[arg(long)]
661        web: bool,
662    },
663    Create {
664        #[arg(long)]
665        tag: String,
666        #[arg(long)]
667        name: Option<String>,
668        #[arg(long)]
669        notes: Option<String>,
670        #[arg(long)]
671        target: Option<String>,
672        #[arg(long)]
673        prerelease: bool,
674    },
675    Upload {
676        tag: String,
677        files: Vec<String>,
678    },
679    Download {
680        tag: String,
681        #[arg(long, default_value = ".")]
682        dir: String,
683        /// Download only assets matching the glob (`*` and `?` supported;
684        /// character classes like `[ab]` are not).
685        #[arg(long)]
686        pattern: Option<String>,
687    },
688    /// Edit a release. At least one flag is required.
689    #[command(group = clap::ArgGroup::new("edit_flags").required(true).multiple(true).args(["name", "notes", "prerelease"]))]
690    Edit {
691        tag: String,
692        #[arg(long)]
693        name: Option<String>,
694        #[arg(long)]
695        notes: Option<String>,
696        #[arg(long)]
697        prerelease: bool,
698    },
699    Delete {
700        tag: String,
701        #[arg(long)]
702        yes: bool,
703    },
704}
705
706/// Repo subcommands. `repo sync` omitted: no fork-synchronize endpoint in v5 swagger (verified 2026-07-18).
707#[derive(Subcommand, Clone)]
708pub enum LabelCmd {
709    List {
710        #[command(flatten)]
711        limit: LimitArgs,
712    },
713    Create {
714        name: String,
715        #[arg(long)]
716        color: String,
717        // Ticket asked for --description but Gitee v5 POST /repos/{owner}/{repo}/labels
718        // has no description param (swagger 2026-07-18).
719    },
720    /// Edit a label. At least one flag is required.
721    #[command(group = clap::ArgGroup::new("edit_flags").required(true).multiple(true).args(["new_name", "color"]))]
722    Edit {
723        name: String,
724        #[arg(long = "name")]
725        new_name: Option<String>,
726        #[arg(long)]
727        color: Option<String>,
728    },
729    Delete {
730        name: String,
731        #[arg(long, short = 'y')]
732        yes: bool,
733    },
734}
735
736#[derive(Subcommand, Clone)]
737pub enum RepoCmd {
738    /// Show repository details. Targets the resolved repo unless given owner/name.
739    View {
740        #[arg(value_name = "REPO")]
741        target: Option<String>,
742        /// Open in a browser instead of printing.
743        #[arg(long)]
744        web: bool,
745    },
746    /// List repositories. Bare lists the authenticated user's; with an arg,
747    /// lists that user/org's public repos.
748    List {
749        owner: Option<String>,
750        #[command(flatten)]
751        limit: LimitArgs,
752    },
753    /// Clone a repository via git.
754    Clone {
755        /// owner/name or a full Gitee URL.
756        spec: String,
757        /// Local directory to clone into.
758        dir: Option<String>,
759        /// Use the SSH URL instead of HTTPS.
760        #[arg(long)]
761        ssh: bool,
762    },
763    /// Fork the resolved repository into your account.
764    Fork {
765        /// After forking, add the new repo as a git remote with this name.
766        #[arg(long = "add-remote")]
767        add_remote: Option<String>,
768    },
769    /// Create a repository under your account or an organization.
770    Create {
771        name: String,
772        /// Create under this organization (POST /orgs/{org}/repos).
773        #[arg(long)]
774        org: Option<String>,
775        #[arg(long)]
776        private: bool,
777        #[arg(long)]
778        description: Option<String>,
779        #[arg(long)]
780        homepage: Option<String>,
781        #[arg(long = "gitignore")]
782        gitignore: Option<String>,
783        #[arg(long)]
784        license: Option<String>,
785    },
786    /// Edit repository settings. At least one flag is required.
787    #[command(group = clap::ArgGroup::new("edit_flags").required(true).multiple(true).args(["description", "homepage", "private", "public", "default_branch"]))]
788    Edit {
789        #[arg(long)]
790        description: Option<String>,
791        #[arg(long)]
792        homepage: Option<String>,
793        #[arg(long, conflicts_with = "public")]
794        private: bool,
795        #[arg(long, conflicts_with = "private")]
796        public: bool,
797        #[arg(long = "default-branch")]
798        default_branch: Option<String>,
799    },
800    /// Rename a repository's URL slug (`path` on the API).
801    Rename {
802        new_path: String,
803    },
804        /// Star the resolved repository.
805    Star,
806    /// Unstar the resolved repository.
807    Unstar,
808    /// Watch (subscribe to) the resolved repository.
809    Watch,
810    /// Unwatch the resolved repository.
811    Unwatch,
812    /// Delete a repository.
813    Delete {
814        #[arg(long)]
815        yes: bool,
816    },
817}
818
819
820#[derive(Subcommand, Clone)]
821pub enum MilestoneCmd {
822    List {
823        #[command(flatten)]
824        list: ListArgs,
825    },
826    /// Show details of a milestone.
827    View {
828        number: i64,
829    },
830    Create {
831        #[arg(long)]
832        title: String,
833        /// Due date (required by the Gitee API — POST without `due_on` returns 400).
834        /// Accepted format: `YYYY-MM-DD`.
835        #[arg(long = "due-on")]
836        due_on: String,
837        #[arg(long)]
838        description: Option<String>,
839        #[arg(long, value_parser = ["open", "closed"])]
840        state: Option<String>,
841    },
842    /// Edit a milestone. At least one flag is required.
843    #[command(group = clap::ArgGroup::new("milestone_edit_flags").required(true).multiple(true).args(["title", "due_on", "description", "state"]))]
844    Edit {
845        number: i64,
846        #[arg(long)]
847        title: Option<String>,
848        #[arg(long = "due-on")]
849        due_on: Option<String>,
850        #[arg(long)]
851        description: Option<String>,
852        #[arg(long, value_parser = ["open", "closed"])]
853        state: Option<String>,
854    },
855}
856
857
858
859#[derive(Subcommand, Clone)]
860pub enum ConfigCmd {
861    List,
862    Get {
863        key: String,
864    },
865    Set {
866        key: String,
867        value: String,
868    },
869}
870
871#[derive(Clone, Debug, PartialEq, Eq)]
872pub enum BuildKind {
873    Cargo,
874    Npm,
875}
876
877#[derive(Subcommand, Clone)]
878pub enum ExtensionCmd {
879    /// List extension commands discovered on PATH and in the managed dir.
880    List,
881    /// Clone a Gitee repo into the managed extensions dir and (optionally) build it.
882    Install {
883        /// `owner/repo` (or a full Gitee URL) of the extension to install.
884        repo: String,
885        /// Build system to run after cloning: `cargo` or `npm`.
886        #[arg(long, value_parser = ["cargo", "npm"])]
887        build: Option<String>,
888        /// Skip the trust confirmation prompt.
889        #[arg(long, short = 'y')]
890        yes: bool,
891    },
892    /// Scaffold a new extension project in the current directory.
893    Create {
894        /// Extension name (without the `gitee-` prefix).
895        name: String,
896        /// Scaffold a Rust extension (Cargo.toml + src/main.rs) instead of a shell script.
897        #[arg(long)]
898        cargo: bool,
899    },
900    /// Remove an installed extension from the managed dir.
901    Remove {
902        name: String,
903        #[arg(long, short = 'y')]
904        yes: bool,
905    },
906    /// Pull (and rebuild, if needed) installed extensions. With no name: all.
907    Upgrade {
908        name: Option<String>,
909    },
910}
911
912#[derive(Subcommand, Clone)]
913pub enum AliasCmd {
914    List,
915    Set {
916        name: String,
917        /// Expansion words (joined with spaces). Prefer quoting in the shell.
918        #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)]
919        expansion: Vec<String>,
920    },
921    Delete {
922        name: String,
923    },
924}
925
926#[derive(Subcommand, Clone)]
927pub enum OrgCmd {
928    List {
929        #[command(flatten)]
930        limit: LimitArgs,
931    },
932}
933
934#[derive(Subcommand, Clone)]
935pub enum SshKeyCmd {
936    List {
937        #[command(flatten)]
938        limit: LimitArgs,
939    },
940    Add {
941        /// Path to the public key file.
942        pubkey_file: String,
943        #[arg(long)]
944        title: Option<String>,
945    },
946    Delete {
947        id: i64,
948        #[arg(long, short = 'y')]
949        yes: bool,
950    },
951}
952
953#[derive(Subcommand, Clone)]
954pub enum CollaboratorCmd {
955    List {
956        #[command(flatten)]
957        limit: LimitArgs,
958    },
959    Add {
960        username: String,
961        /// Permission: pull | push | admin (English enums).
962        #[arg(long, default_value = "push")]
963        permission: String,
964    },
965    Remove {
966        username: String,
967        #[arg(long, short = 'y')]
968        yes: bool,
969    },
970}
971
972#[derive(Subcommand, Clone)]
973pub enum WebhookCmd {
974    List {
975        #[command(flatten)]
976        limit: LimitArgs,
977    },
978    Create {
979        #[arg(long)]
980        url: String,
981        /// Event flags (comma-separated or repeatable): push_events, tag_push_events,
982        /// issues_events, merge_requests_events, note_events.
983        #[arg(long)]
984        events: Vec<String>,
985        #[arg(long)]
986        password: Option<String>,
987    },
988    Delete {
989        id: i64,
990        #[arg(long, short = 'y')]
991        yes: bool,
992    },
993}
994
995#[derive(Subcommand, Clone)]
996pub enum AuthCmd {
997    /// Store a personal access token (validated against the API unless --force).
998    Login {
999        #[arg(long)]
1000        token: Option<String>,
1001        /// Skip the token-validation probe.
1002        #[arg(long)]
1003        force: bool,
1004    },
1005    /// Show whether you are logged in and where the token is stored.
1006    Status,
1007    /// Print the active token (e.g. to pipe into another tool).
1008    Token,
1009    /// Forget the stored token for the current host.
1010    Logout,
1011    /// Configure git to use gitee as a credential helper for this host.
1012    SetupGit,
1013    /// Switch the active user for this host.
1014    Switch {
1015        #[arg(long)]
1016        user: String,
1017    },
1018    /// Git credential-helper protocol (usually invoked by git, not humans).
1019    #[command(subcommand)]
1020    GitCredential(GitCredentialCmd),
1021}
1022
1023#[derive(Subcommand, Clone)]
1024pub enum GitCredentialCmd {
1025    Get,
1026    Store,
1027    Erase,
1028}
1029
1030#[cfg(test)]
1031mod parse_tests {
1032    use super::{AliasCmd, AuthCmd, Cli, CollaboratorCmd, Command, ConfigCmd, ExtensionCmd, GistCmd, GitCredentialCmd, IssueCmd, IssueCommentCmd, IssueLabelCmd, MilestoneCmd, OrgCmd, PrAssigneeCmd, PrCmd, PrCommentCmd, PrLabelCmd, PrTesterCmd, ReleaseCmd, RepoCmd, SshKeyCmd, WebhookCmd};
1033    use clap::Parser;
1034
1035    #[test]
1036    fn pr_create_parses_without_title_for_interactive() {
1037        let cli = Cli::try_parse_from(["gitee", "pr", "create"])
1038            .expect("pr create without flags should parse (interactive or usage at runtime)");
1039        let Command::Pr(PrCmd::Create { title, fill, .. }) = cli.cmd else {
1040            panic!("expected pr create");
1041        };
1042        assert!(title.is_none());
1043        assert!(!fill);
1044
1045        let cli = Cli::try_parse_from(["gitee", "pr", "create", "--fill"])
1046            .expect("--fill alone should parse");
1047        let Command::Pr(PrCmd::Create { title, fill, .. }) = cli.cmd else {
1048            panic!("expected pr create");
1049        };
1050        assert!(title.is_none());
1051        assert!(fill);
1052    }
1053
1054    #[test]
1055    fn issue_create_parses_without_title_for_interactive() {
1056        let cli = Cli::try_parse_from(["gitee", "issue", "create"])
1057            .expect("issue create without flags should parse (interactive or usage at runtime)");
1058        let Command::Issue(IssueCmd::Create { title, .. }) = cli.cmd else {
1059            panic!("expected issue create");
1060        };
1061        assert!(title.is_none());
1062    }
1063
1064    #[test]
1065    fn search_requires_query_positional() {
1066        assert!(Cli::try_parse_from(["gitee", "search", "repos"]).is_err());
1067        assert!(Cli::try_parse_from(["gitee", "search", "issues"]).is_err());
1068        assert!(Cli::try_parse_from(["gitee", "search", "users"]).is_err());
1069    }
1070
1071    #[test]
1072    fn search_parses_flags() {
1073        let cli = Cli::try_parse_from([
1074            "gitee", "search", "repos", "gitee",
1075            "--owner", "oschina",
1076            "--language", "Rust",
1077            "--fork",
1078            "--sort", "stars_count",
1079            "--order", "desc",
1080            "--limit", "5",
1081        ])
1082        .expect("search repos should parse");
1083        let Command::Search(super::SearchCmd::Repos {
1084            query,
1085            owner,
1086            language,
1087            fork,
1088            sort,
1089            order,
1090            limit,
1091        }) = cli.cmd
1092        else {
1093            panic!("expected search repos");
1094        };
1095        assert_eq!(query, "gitee");
1096        assert_eq!(owner.as_deref(), Some("oschina"));
1097        assert_eq!(language.as_deref(), Some("Rust"));
1098        assert!(fork);
1099        assert_eq!(sort.as_deref(), Some("stars_count"));
1100        assert_eq!(order.as_deref(), Some("desc"));
1101        assert_eq!(limit.limit, 5);
1102
1103        let cli = Cli::try_parse_from([
1104            "gitee", "search", "issues", "login",
1105            "--state", "open",
1106            "--author", "alice",
1107            "--assignee", "bob",
1108            "--label", "bug",
1109            "--language", "Go",
1110            "--sort", "updated_at",
1111            "--order", "asc",
1112            "--limit", "3",
1113        ])
1114        .expect("search issues should parse");
1115        let Command::Search(super::SearchCmd::Issues {
1116            query,
1117            state,
1118            author,
1119            assignee,
1120            label,
1121            language,
1122            sort,
1123            order,
1124            limit,
1125        }) = cli.cmd
1126        else {
1127            panic!("expected search issues");
1128        };
1129        assert_eq!(query, "login");
1130        assert_eq!(state.as_deref(), Some("open"));
1131        assert_eq!(author.as_deref(), Some("alice"));
1132        assert_eq!(assignee.as_deref(), Some("bob"));
1133        assert_eq!(label.as_deref(), Some("bug"));
1134        assert_eq!(language.as_deref(), Some("Go"));
1135        assert_eq!(sort.as_deref(), Some("updated_at"));
1136        assert_eq!(order.as_deref(), Some("asc"));
1137        assert_eq!(limit.limit, 3);
1138
1139        let cli = Cli::try_parse_from([
1140            "gitee", "search", "users", "kip",
1141            "--sort", "followers_count",
1142            "--order", "desc",
1143            "--limit", "3",
1144        ])
1145        .expect("search users should parse");
1146        let Command::Search(super::SearchCmd::Users {
1147            query,
1148            sort,
1149            order,
1150            limit,
1151        }) = cli.cmd
1152        else {
1153            panic!("expected search users");
1154        };
1155        assert_eq!(query, "kip");
1156        assert_eq!(sort.as_deref(), Some("followers_count"));
1157        assert_eq!(order.as_deref(), Some("desc"));
1158        assert_eq!(limit.limit, 3);
1159    }
1160
1161    #[test]
1162    fn search_issues_accepts_global_repo_flag() {
1163        let cli = Cli::try_parse_from([
1164            "gitee",
1165            "--repo",
1166            "oschina/gitee-cli",
1167            "search",
1168            "issues",
1169            "bug",
1170            "--limit",
1171            "3",
1172        ])
1173        .expect("search issues with global --repo should parse");
1174        assert_eq!(cli.repo.as_deref(), Some("oschina/gitee-cli"));
1175        let Command::Search(super::SearchCmd::Issues { query, limit, .. }) = cli.cmd else {
1176            panic!("expected search issues");
1177        };
1178        assert_eq!(query, "bug");
1179        assert_eq!(limit.limit, 3);
1180    }
1181
1182
1183    #[test]
1184    fn pr_create_parses_full_flag_surface() {
1185        let cli = Cli::try_parse_from([
1186            "gitee", "pr", "create",
1187            "--title", "T",
1188            "--assignee", "me",
1189            "--tester", "qa1",
1190            "--label", "bug,ui",
1191            "--milestone", "v1.0",
1192            "--close-issue", "I1AB2C",
1193        ])
1194        .expect("pr create should parse");
1195        let Command::Pr(PrCmd::Create {
1196            assignee,
1197            tester,
1198            label,
1199            milestone,
1200            close_issue,
1201            ..
1202        }) = cli.cmd
1203        else {
1204            panic!("expected pr create");
1205        };
1206        assert_eq!(assignee, vec!["me".to_string()]);
1207        assert_eq!(tester, vec!["qa1".to_string()]);
1208        assert_eq!(label, vec!["bug,ui".to_string()]);
1209        assert_eq!(milestone.as_deref(), Some("v1.0"));
1210        assert_eq!(close_issue.as_deref(), Some("I1AB2C"));
1211    }
1212
1213    #[test]
1214    fn pr_create_parses_draft_flag() {
1215        let cli = Cli::try_parse_from(["gitee", "pr", "create", "--title", "T", "--draft"])
1216            .expect("pr create --draft should parse");
1217        let Command::Pr(PrCmd::Create { draft, .. }) = cli.cmd else {
1218            panic!("expected pr create");
1219        };
1220        assert!(draft);
1221    }
1222
1223    #[test]
1224    fn pr_ready_parses_number_and_undo() {
1225        let cli = Cli::try_parse_from(["gitee", "pr", "ready", "12"])
1226            .expect("pr ready should parse");
1227        let Command::Pr(PrCmd::Ready { number, undo }) = cli.cmd else {
1228            panic!("expected pr ready");
1229        };
1230        assert_eq!(number, 12);
1231        assert!(!undo);
1232
1233        let cli = Cli::try_parse_from(["gitee", "pr", "ready", "12", "--undo"])
1234            .expect("pr ready --undo should parse");
1235        let Command::Pr(PrCmd::Ready { number, undo }) = cli.cmd else {
1236            panic!("expected pr ready --undo");
1237        };
1238        assert_eq!(number, 12);
1239        assert!(undo);
1240    }
1241
1242    #[test]
1243    fn release_edit_requires_at_least_one_flag() {
1244        let r = Cli::try_parse_from(["gitee", "release", "edit", "v1.0"]);
1245        assert!(r.is_err(), "release edit with no flags must fail");
1246    }
1247
1248    #[test]
1249    fn release_download_parses_flags() {
1250        let cli = Cli::try_parse_from([
1251            "gitee",
1252            "release",
1253            "download",
1254            "v1.0",
1255            "--dir",
1256            "/tmp/out",
1257            "--pattern",
1258            "*.tar.xz",
1259        ])
1260        .expect("release download should parse");
1261        let Command::Release(ReleaseCmd::Download { tag, dir, pattern }) = cli.cmd else {
1262            panic!("expected release download");
1263        };
1264        assert_eq!(tag, "v1.0");
1265        assert_eq!(dir, "/tmp/out");
1266        assert_eq!(pattern.as_deref(), Some("*.tar.xz"));
1267    }
1268
1269    #[test]
1270    fn issue_edit_requires_at_least_one_flag() {
1271        let r = Cli::try_parse_from(["gitee", "issue", "edit", "I1AB"]);
1272        assert!(r.is_err(), "issue edit with no flags must fail");
1273    }
1274
1275    #[test]
1276    fn issue_edit_state_alone_parses() {
1277        let cli = Cli::try_parse_from(["gitee", "issue", "edit", "I1AB", "--state", "progressing"])
1278            .expect("issue edit --state alone should parse");
1279        let Command::Issue(IssueCmd::Edit { number, state, .. }) = cli.cmd else {
1280            panic!("expected issue edit");
1281        };
1282        assert_eq!(number, "I1AB");
1283        assert_eq!(state.as_deref(), Some("progressing"));
1284    }
1285
1286    #[test]
1287    fn gist_create_requires_at_least_one_file() {
1288        let r = Cli::try_parse_from(["gitee", "gist", "create"]);
1289        assert!(r.is_err(), "gist create with no files must fail");
1290    }
1291
1292    #[test]
1293    fn gist_flags_parse() {
1294        let cli = Cli::try_parse_from([
1295            "gitee", "gist", "create", "a.txt",
1296            "--desc", "my snippet",
1297            "--public",
1298            "--filename", "b.txt",
1299        ])
1300        .expect("gist create should parse");
1301        let Command::Gist(GistCmd::Create {
1302            files,
1303            desc,
1304            public,
1305            filename,
1306            ..
1307        }) = cli.cmd
1308        else {
1309            panic!("expected gist create");
1310        };
1311        assert_eq!(files, vec!["a.txt".to_string()]);
1312        assert_eq!(desc.as_deref(), Some("my snippet"));
1313        assert!(public);
1314        assert_eq!(filename.as_deref(), Some("b.txt"));
1315
1316        let cli = Cli::try_parse_from(["gitee", "gist", "delete", "abc123", "--yes"])
1317            .expect("gist delete should parse");
1318        let Command::Gist(GistCmd::Delete { id, yes }) = cli.cmd else {
1319            panic!("expected gist delete");
1320        };
1321        assert_eq!(id, "abc123");
1322        assert!(yes);
1323    }
1324
1325
1326    #[test]
1327    fn issue_edit_and_create_parse_new_flags() {
1328        let cli = Cli::try_parse_from([
1329            "gitee", "issue", "edit", "I1AB",
1330            "--title", "Retitle",
1331            "--label", "bug",
1332            "--assignee", "dev1",
1333            "--milestone", "v1.0",
1334            "--security-hole",
1335        ])
1336        .expect("issue edit should parse");
1337        let Command::Issue(IssueCmd::Edit {
1338            number,
1339            title,
1340            security_hole,
1341            ..
1342        }) = cli.cmd
1343        else {
1344            panic!("expected issue edit");
1345        };
1346        assert_eq!(number, "I1AB");
1347        assert_eq!(title.as_deref(), Some("Retitle"));
1348        assert!(security_hole);
1349
1350        let cli = Cli::try_parse_from([
1351            "gitee", "issue", "create",
1352            "--title", "T",
1353            "--milestone", "3",
1354            "--security-hole",
1355        ])
1356        .expect("issue create should parse");
1357        let Command::Issue(IssueCmd::Create {
1358            milestone,
1359            security_hole,
1360            ..
1361        }) = cli.cmd
1362        else {
1363            panic!("expected issue create");
1364        };
1365        assert_eq!(milestone.as_deref(), Some("3"));
1366        assert!(security_hole);
1367    }
1368
1369    #[test]
1370    fn repo_edit_requires_at_least_one_flag() {
1371        let r = Cli::try_parse_from(["gitee", "repo", "edit"]);
1372        assert!(r.is_err(), "repo edit with no flags must fail");
1373    }
1374
1375    #[test]
1376    fn repo_edit_private_public_conflict() {
1377        let r = Cli::try_parse_from(["gitee", "repo", "edit", "--private", "--public"]);
1378        assert!(r.is_err(), "--private and --public must conflict");
1379    }
1380
1381    #[test]
1382    fn repo_create_flags_parse() {
1383        let cli = Cli::try_parse_from([
1384            "gitee",
1385            "repo",
1386            "create",
1387            "my-repo",
1388            "--org",
1389            "acme",
1390            "--private",
1391            "--description",
1392            "desc",
1393            "--homepage",
1394            "https://example.com",
1395            "--gitignore",
1396            "Rust",
1397            "--license",
1398            "MIT",
1399        ])
1400        .expect("repo create should parse");
1401        let Command::Repo(RepoCmd::Create {
1402            name,
1403            org,
1404            private,
1405            description,
1406            homepage,
1407            gitignore,
1408            license,
1409        }) = cli.cmd
1410        else {
1411            panic!("expected repo create");
1412        };
1413        assert_eq!(name, "my-repo");
1414        assert_eq!(org.as_deref(), Some("acme"));
1415        assert!(private);
1416        assert_eq!(description.as_deref(), Some("desc"));
1417        assert_eq!(homepage.as_deref(), Some("https://example.com"));
1418        assert_eq!(gitignore.as_deref(), Some("Rust"));
1419        assert_eq!(license.as_deref(), Some("MIT"));
1420    }
1421
1422    #[test]
1423    fn pr_edit_requires_at_least_one_flag() {
1424        let r = Cli::try_parse_from(["gitee", "pr", "edit", "5"]);
1425        assert!(r.is_err(), "pr edit with no flags must fail");
1426    }
1427
1428    #[test]
1429    fn pr_test_parses() {
1430        let cli = Cli::try_parse_from(["gitee", "pr", "test", "12"])
1431            .expect("pr test should parse");
1432        let Command::Pr(PrCmd::Test { number, force }) = cli.cmd else {
1433            panic!("expected pr test");
1434        };
1435        assert_eq!(number, 12);
1436        assert!(!force);
1437
1438        let cli = Cli::try_parse_from(["gitee", "pr", "test", "12", "--force"])
1439            .expect("pr test --force should parse");
1440        let Command::Pr(PrCmd::Test { number, force }) = cli.cmd else {
1441            panic!("expected pr test");
1442        };
1443        assert_eq!(number, 12);
1444        assert!(force);
1445    }
1446
1447    #[test]
1448    fn label_edit_requires_at_least_one_flag() {
1449        let r = Cli::try_parse_from(["gitee", "label", "edit", "bug"]);
1450        assert!(r.is_err(), "label edit with no flags must fail");
1451    }
1452
1453    #[test]
1454    fn label_create_requires_color() {
1455        let r = Cli::try_parse_from(["gitee", "label", "create", "bug"]);
1456        assert!(r.is_err(), "label create without --color must fail");
1457    }
1458
1459    #[test]
1460    fn pr_edit_parses_accumulating_flags() {
1461        let cli = Cli::try_parse_from([
1462            "gitee", "pr", "edit", "5",
1463            "--title", "New title",
1464            "--label", "a,b",
1465            "--label", "c",
1466            "--assignee", "dev1",
1467            "--tester", "qa1",
1468            "--milestone", "v1.0",
1469        ])
1470        .expect("pr edit should parse");
1471        let Command::Pr(PrCmd::Edit {
1472            number,
1473            title,
1474            label,
1475            assignee,
1476            tester,
1477            milestone,
1478            ..
1479        }) = cli.cmd
1480        else {
1481            panic!("expected pr edit, got {:?}", std::mem::discriminant(&cli.cmd));
1482        };
1483        assert_eq!(number, 5);
1484        assert_eq!(title.as_deref(), Some("New title"));
1485        assert_eq!(label, vec!["a,b".to_string(), "c".to_string()]);
1486        assert_eq!(assignee, vec!["dev1".to_string()]);
1487        assert_eq!(tester, vec!["qa1".to_string()]);
1488        assert_eq!(milestone.as_deref(), Some("v1.0"));
1489    }
1490
1491    #[test]
1492    fn jq_without_json_is_a_usage_error() {
1493        let r = Cli::try_parse_from(["gitee", "pr", "list", "--jq", ".[0]"]);
1494        assert!(r.is_err(), "--jq without --json must fail");
1495    }
1496
1497    #[test]
1498    fn milestone_create_requires_title_and_due_on() {
1499        assert!(Cli::try_parse_from(["gitee", "milestone", "create"]).is_err());
1500        assert!(Cli::try_parse_from(["gitee", "milestone", "create", "--title", "T"]).is_err());
1501        let cli = Cli::try_parse_from([
1502            "gitee", "milestone", "create", "--title", "T", "--due-on", "2026-12-31",
1503        ])
1504        .expect("milestone create should parse");
1505        let Command::Milestone(MilestoneCmd::Create { title, due_on, .. }) = cli.cmd else {
1506            panic!("expected milestone create");
1507        };
1508        assert_eq!(title, "T");
1509        assert_eq!(due_on, "2026-12-31");
1510    }
1511
1512    #[test]
1513    fn milestone_edit_requires_at_least_one_flag() {
1514        let r = Cli::try_parse_from(["gitee", "milestone", "edit", "1"]);
1515        assert!(r.is_err(), "milestone edit with no flags must fail");
1516    }
1517
1518
1519    #[test]
1520    fn jq_parses_after_bare_json_flag() {
1521        let cli = Cli::try_parse_from(["gitee", "pr", "list", "--json", "--jq", ".[0].title"])
1522            .expect("--json --jq should parse");
1523        assert_eq!(cli.json.as_deref(), Some(""));
1524        assert_eq!(cli.jq.as_deref(), Some(".[0].title"));
1525    }
1526
1527    #[test]
1528    fn jq_parses_after_json_field_projection() {
1529        let cli = Cli::try_parse_from([
1530            "gitee",
1531            "issue",
1532            "list",
1533            "--json",
1534            "number,title",
1535            "--jq",
1536            "map(.number)",
1537        ])
1538        .expect("--json fields --jq should parse");
1539        assert_eq!(cli.json.as_deref(), Some("number,title"));
1540        assert_eq!(cli.jq.as_deref(), Some("map(.number)"));
1541    }
1542
1543    #[test]
1544    fn status_parses() {
1545        let cli = Cli::try_parse_from(["gitee", "status"]).expect("status should parse");
1546        let Command::Status { limit } = cli.cmd else {
1547            panic!("expected status");
1548        };
1549        assert_eq!(limit.limit, 30);
1550
1551        let cli = Cli::try_parse_from(["gitee", "status", "--limit", "5"])
1552            .expect("status with limit should parse");
1553        let Command::Status { limit } = cli.cmd else {
1554            panic!("expected status with limit");
1555        };
1556        assert_eq!(limit.limit, 5);
1557    }
1558
1559    #[test]
1560    fn pr_status_parses() {
1561        let cli = Cli::try_parse_from(["gitee", "pr", "status"]).expect("pr status should parse");
1562        let Command::Pr(PrCmd::Status { limit }) = cli.cmd else {
1563            panic!("expected pr status");
1564        };
1565        assert_eq!(limit.limit, 30);
1566
1567        let cli = Cli::try_parse_from(["gitee", "pr", "status", "--limit", "5"])
1568            .expect("pr status --limit should parse");
1569        let Command::Pr(PrCmd::Status { limit }) = cli.cmd else {
1570            panic!("expected pr status");
1571        };
1572        assert_eq!(limit.limit, 5);
1573    }
1574
1575    #[test]
1576    fn issue_status_parses() {
1577        let cli = Cli::try_parse_from(["gitee", "issue", "status"])
1578            .expect("issue status should parse");
1579        let Command::Issue(IssueCmd::Status { limit }) = cli.cmd else {
1580            panic!("expected issue status");
1581        };
1582        assert_eq!(limit.limit, 30);
1583
1584        let cli = Cli::try_parse_from(["gitee", "issue", "status", "--limit", "5"])
1585            .expect("issue status --limit should parse");
1586        let Command::Issue(IssueCmd::Status { limit }) = cli.cmd else {
1587            panic!("expected issue status");
1588        };
1589        assert_eq!(limit.limit, 5);
1590    }
1591
1592    #[test]
1593    fn org_list_parses_limit() {
1594        let cli = Cli::try_parse_from(["gitee", "org", "list", "--limit", "5"]).expect("org list");
1595        let Command::Org(OrgCmd::List { limit }) = cli.cmd else { panic!("expected org list") };
1596        assert_eq!(limit.limit, 5);
1597    }
1598
1599    #[test]
1600    fn ssh_key_commands_parse() {
1601        let cli = Cli::try_parse_from(["gitee", "ssh-key", "list"]).expect("ssh-key list");
1602        assert!(matches!(cli.cmd, Command::SshKey(SshKeyCmd::List { .. })));
1603
1604        let cli = Cli::try_parse_from([
1605            "gitee", "ssh-key", "add", "~/.ssh/id_ed25519.pub", "--title", "laptop",
1606        ]).expect("ssh-key add");
1607        let Command::SshKey(SshKeyCmd::Add { pubkey_file, title }) = cli.cmd else { panic!("add") };
1608        assert_eq!(pubkey_file, "~/.ssh/id_ed25519.pub");
1609        assert_eq!(title.as_deref(), Some("laptop"));
1610
1611        let cli = Cli::try_parse_from(["gitee", "ssh-key", "delete", "99", "--yes"]).expect("delete");
1612        let Command::SshKey(SshKeyCmd::Delete { id, yes }) = cli.cmd else { panic!("delete") };
1613        assert_eq!(id, 99);
1614        assert!(yes);
1615    }
1616
1617    #[test]
1618    fn collaborator_commands_parse() {
1619        let cli = Cli::try_parse_from([
1620            "gitee", "collaborator", "add", "alice", "--permission", "admin",
1621        ]).expect("collaborator add");
1622        let Command::Collaborator(CollaboratorCmd::Add { username, permission }) = cli.cmd else { panic!("add") };
1623        assert_eq!(username, "alice");
1624        assert_eq!(permission, "admin");
1625
1626        let cli = Cli::try_parse_from(["gitee", "collaborator", "remove", "alice", "-y"]).expect("remove");
1627        let Command::Collaborator(CollaboratorCmd::Remove { username, yes }) = cli.cmd else { panic!("remove") };
1628        assert_eq!(username, "alice");
1629        assert!(yes);
1630    }
1631
1632    #[test]
1633    fn webhook_commands_parse() {
1634        let cli = Cli::try_parse_from([
1635            "gitee", "webhook", "create",
1636            "--url", "https://example.com/hook",
1637            "--events", "push_events,issues_events",
1638            "--password", "s3cret",
1639        ]).expect("webhook create");
1640        let Command::Webhook(WebhookCmd::Create { url, events, password }) = cli.cmd else { panic!("create") };
1641        assert_eq!(url, "https://example.com/hook");
1642        assert_eq!(events, vec!["push_events,issues_events".to_string()]);
1643        assert_eq!(password.as_deref(), Some("s3cret"));
1644
1645        let cli = Cli::try_parse_from(["gitee", "webhook", "delete", "55", "--yes"]).expect("delete");
1646        let Command::Webhook(WebhookCmd::Delete { id, yes }) = cli.cmd else { panic!("delete") };
1647        assert_eq!(id, 55);
1648        assert!(yes);
1649
1650        let cli = Cli::try_parse_from([
1651            "gitee", "webhook", "create",
1652            "--url", "https://example.com/hook",
1653            "--events", "pull_requests_events",
1654        ]).expect("webhook alias event");
1655        let Command::Webhook(WebhookCmd::Create { events, .. }) = cli.cmd else { panic!("create") };
1656        assert_eq!(events, vec!["pull_requests_events".to_string()]);
1657    }
1658
1659    #[test]
1660    fn repo_star_watch_parse() {
1661        let cli = Cli::try_parse_from(["gitee", "repo", "star"]).expect("star");
1662        assert!(matches!(cli.cmd, Command::Repo(RepoCmd::Star)));
1663        let cli = Cli::try_parse_from(["gitee", "repo", "unstar"]).expect("unstar");
1664        assert!(matches!(cli.cmd, Command::Repo(RepoCmd::Unstar)));
1665        let cli = Cli::try_parse_from(["gitee", "repo", "watch"]).expect("watch");
1666        assert!(matches!(cli.cmd, Command::Repo(RepoCmd::Watch)));
1667        let cli = Cli::try_parse_from(["gitee", "repo", "unwatch"]).expect("unwatch");
1668        assert!(matches!(cli.cmd, Command::Repo(RepoCmd::Unwatch)));
1669    }
1670
1671    #[test]
1672    fn config_and_alias_parse() {
1673        let cli = Cli::try_parse_from(["gitee", "config", "set", "host", "gitee.com"]).unwrap();
1674        let Command::Config(ConfigCmd::Set { key, value }) = cli.cmd else { panic!("config set") };
1675        assert_eq!(key, "host");
1676        assert_eq!(value, "gitee.com");
1677
1678        let cli = Cli::try_parse_from(["gitee", "alias", "set", "co", "pr", "checkout"]).unwrap();
1679        let Command::Alias(AliasCmd::Set { name, expansion }) = cli.cmd else { panic!("alias set") };
1680        assert_eq!(name, "co");
1681        assert_eq!(expansion, vec!["pr", "checkout"]);
1682
1683        let cli = Cli::try_parse_from(["gitee", "alias", "delete", "co"]).unwrap();
1684        assert!(matches!(cli.cmd, Command::Alias(AliasCmd::Delete { .. })));
1685    }
1686
1687    #[test]
1688    fn auth_setup_switch_credential_parse() {
1689        let cli = Cli::try_parse_from(["gitee", "auth", "setup-git"]).unwrap();
1690        assert!(matches!(cli.cmd, Command::Auth(AuthCmd::SetupGit)));
1691        let cli = Cli::try_parse_from(["gitee", "auth", "switch", "--user", "kip"]).unwrap();
1692        let Command::Auth(AuthCmd::Switch { user }) = cli.cmd else { panic!("switch") };
1693        assert_eq!(user, "kip");
1694        let cli = Cli::try_parse_from(["gitee", "auth", "git-credential", "get"]).unwrap();
1695        assert!(matches!(
1696            cli.cmd,
1697            Command::Auth(AuthCmd::GitCredential(GitCredentialCmd::Get))
1698        ));
1699    }
1700
1701    #[test]
1702    fn extension_list_parse() {
1703        let cli = Cli::try_parse_from(["gitee", "extension", "list"]).unwrap();
1704        assert!(matches!(cli.cmd, Command::Extension(ExtensionCmd::List)));
1705    }
1706
1707    #[test]
1708    fn extension_install_parse() {
1709        let cli = Cli::try_parse_from([
1710            "gitee", "extension", "install", "owner/my-ext", "--build", "cargo", "--yes",
1711        ])
1712        .unwrap();
1713        let Command::Extension(ExtensionCmd::Install { repo, build, yes }) = cli.cmd else {
1714            panic!("expected install");
1715        };
1716        assert_eq!(repo, "owner/my-ext");
1717        assert_eq!(build.as_deref(), Some("cargo"));
1718        assert!(yes);
1719
1720        let cli = Cli::try_parse_from(["gitee", "extension", "install", "owner/ext"]).unwrap();
1721        let Command::Extension(ExtensionCmd::Install { build, yes, .. }) = cli.cmd else {
1722            panic!("expected install");
1723        };
1724        assert!(build.is_none());
1725        assert!(!yes);
1726    }
1727
1728    #[test]
1729    fn extension_install_rejects_bad_build_value() {
1730        assert!(Cli::try_parse_from([
1731            "gitee", "extension", "install", "owner/ext", "--build", "go"
1732        ])
1733        .is_err());
1734    }
1735
1736    #[test]
1737    fn extension_create_parse() {
1738        let cli = Cli::try_parse_from(["gitee", "extension", "create", "demo"]).unwrap();
1739        let Command::Extension(ExtensionCmd::Create { name, cargo }) = cli.cmd else {
1740            panic!("expected create");
1741        };
1742        assert_eq!(name, "demo");
1743        assert!(!cargo);
1744
1745        let cli = Cli::try_parse_from(["gitee", "extension", "create", "demo", "--cargo"]).unwrap();
1746        let Command::Extension(ExtensionCmd::Create { cargo, .. }) = cli.cmd else {
1747            panic!("expected create");
1748        };
1749        assert!(cargo);
1750    }
1751
1752    #[test]
1753    fn extension_remove_parse() {
1754        let cli = Cli::try_parse_from(["gitee", "extension", "remove", "demo", "--yes"]).unwrap();
1755        let Command::Extension(ExtensionCmd::Remove { name, yes }) = cli.cmd else {
1756            panic!("expected remove");
1757        };
1758        assert_eq!(name, "demo");
1759        assert!(yes);
1760    }
1761
1762    #[test]
1763    fn extension_upgrade_parse() {
1764        let cli = Cli::try_parse_from(["gitee", "extension", "upgrade"]).unwrap();
1765        let Command::Extension(ExtensionCmd::Upgrade { name }) = cli.cmd else {
1766            panic!("expected upgrade");
1767        };
1768        assert!(name.is_none());
1769
1770        let cli = Cli::try_parse_from(["gitee", "extension", "upgrade", "demo"]).unwrap();
1771        let Command::Extension(ExtensionCmd::Upgrade { name }) = cli.cmd else {
1772            panic!("expected upgrade");
1773        };
1774        assert_eq!(name.as_deref(), Some("demo"));
1775    }
1776
1777    #[test]
1778    fn external_extension_parse() {
1779        let cli = Cli::try_parse_from(["gitee", "myext", "arg1", "--flag"]).unwrap();
1780        let Command::External(args) = cli.cmd else { panic!("external") };
1781        assert_eq!(args.len(), 3);
1782        assert_eq!(args[0], "myext");
1783        assert_eq!(args[1], "arg1");
1784        assert_eq!(args[2], "--flag");
1785    }
1786
1787    #[test]
1788    fn browse_and_web_parse() {
1789        let cli = Cli::try_parse_from(["gitee", "browse"]).unwrap();
1790        assert!(matches!(cli.cmd, Command::Browse));
1791        let cli = Cli::try_parse_from(["gitee", "pr", "view", "12", "--web"]).unwrap();
1792        let Command::Pr(PrCmd::View { number, web, merged }) = cli.cmd else { panic!("pr view") };
1793        assert_eq!(number, 12);
1794        assert!(web);
1795        assert!(!merged);
1796        let cli = Cli::try_parse_from(["gitee", "pr", "view", "12", "--merged"]).unwrap();
1797        let Command::Pr(PrCmd::View { number, merged, web, .. }) = cli.cmd else { panic!("pr view --merged") };
1798        assert_eq!(number, 12);
1799        assert!(merged);
1800        assert!(!web);
1801        let cli = Cli::try_parse_from(["gitee", "issue", "view", "I1", "--web"]).unwrap();
1802        let Command::Issue(IssueCmd::View { number, web }) = cli.cmd else { panic!("issue view") };
1803        assert_eq!(number, "I1");
1804        assert!(web);
1805        let cli = Cli::try_parse_from(["gitee", "release", "view", "v1", "--web"]).unwrap();
1806        let Command::Release(ReleaseCmd::View { tag, web }) = cli.cmd else { panic!("release view") };
1807        assert_eq!(tag, "v1");
1808        assert!(web);
1809        let cli = Cli::try_parse_from(["gitee", "repo", "view", "--web"]).unwrap();
1810        let Command::Repo(RepoCmd::View { web, .. }) = cli.cmd else { panic!("repo view") };
1811        assert!(web);
1812    }
1813
1814    #[test]
1815    fn issue_comment_create_parses() {
1816        let cli = Cli::try_parse_from([
1817            "gitee", "issue", "comment", "create", "I88", "-m", "looking into it",
1818        ])
1819        .expect("issue comment create should parse");
1820        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::Create { number, body })) = cli.cmd
1821        else {
1822            panic!("expected issue comment create");
1823        };
1824        assert_eq!(number, "I88");
1825        assert_eq!(body.body, "looking into it");
1826    }
1827
1828    #[test]
1829    fn pr_comment_create_parses() {
1830        let cli = Cli::try_parse_from([
1831            "gitee", "pr", "comment", "create", "42", "-m", "LGTM",
1832        ])
1833        .expect("pr comment create should parse");
1834        let Command::Pr(PrCmd::Comment(PrCommentCmd::Create {
1835            number,
1836            body,
1837            path,
1838            position,
1839            commit_id,
1840        })) = cli.cmd
1841        else {
1842            panic!("expected pr comment create");
1843        };
1844        assert_eq!(number, 42);
1845        assert_eq!(body.body, "LGTM");
1846        assert!(path.is_none());
1847        assert!(position.is_none());
1848        assert!(commit_id.is_none());
1849    }
1850
1851    #[test]
1852    fn pr_comment_create_positional_flags_parse() {
1853        let cli = Cli::try_parse_from([
1854            "gitee",
1855            "pr",
1856            "comment",
1857            "create",
1858            "12",
1859            "-m",
1860            "nit",
1861            "--path",
1862            "src/main.rs",
1863            "--position",
1864            "7",
1865            "--commit-id",
1866            "deadbeef",
1867        ])
1868        .expect("pr comment create with positional flags should parse");
1869        let Command::Pr(PrCmd::Comment(PrCommentCmd::Create {
1870            number,
1871            body,
1872            path,
1873            position,
1874            commit_id,
1875        })) = cli.cmd
1876        else {
1877            panic!("expected pr comment create");
1878        };
1879        assert_eq!(number, 12);
1880        assert_eq!(body.body, "nit");
1881        assert_eq!(path.as_deref(), Some("src/main.rs"));
1882        assert_eq!(position, Some(7));
1883        assert_eq!(commit_id.as_deref(), Some("deadbeef"));
1884    }
1885
1886    #[test]
1887    fn pr_comment_create_position_help_documents_diff_line_index() {
1888        use clap::CommandFactory;
1889
1890        let mut cmd = Cli::command();
1891        let help = cmd
1892            .find_subcommand_mut("pr")
1893            .and_then(|c| c.find_subcommand_mut("comment"))
1894            .and_then(|c| c.find_subcommand_mut("create"))
1895            .expect("pr comment create subcommand")
1896            .render_long_help()
1897            .ansi()
1898            .to_string();
1899        assert!(
1900            help.contains("diff-line index"),
1901            "help should document position is a diff-line index: {help}"
1902        );
1903        assert!(
1904            help.contains("not the file line number"),
1905            "help should warn position is not a file line number: {help}"
1906        );
1907    }
1908
1909    #[test]
1910    fn old_flat_comment_form_is_rejected() {
1911        assert!(
1912            Cli::try_parse_from(["gitee", "issue", "comment", "I88", "-m", "x"]).is_err(),
1913            "old issue comment form must fail"
1914        );
1915        assert!(
1916            Cli::try_parse_from(["gitee", "pr", "comment", "42", "-m", "x"]).is_err(),
1917            "old pr comment form must fail"
1918        );
1919    }
1920
1921    #[test]
1922    fn issue_comment_list_parses_limit() {
1923        let cli = Cli::try_parse_from([
1924            "gitee", "issue", "comment", "list", "I88", "--limit", "5",
1925        ])
1926        .expect("issue comment list should parse");
1927        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::List { number, limit })) = cli.cmd
1928        else {
1929            panic!("expected issue comment list");
1930        };
1931        assert_eq!(number, "I88");
1932        assert_eq!(limit.limit, 5);
1933    }
1934
1935    #[test]
1936    fn pr_comment_list_parses_type_and_limit() {
1937        let cli = Cli::try_parse_from([
1938            "gitee", "pr", "comment", "list", "42", "--type", "diff", "--limit", "10",
1939        ])
1940        .expect("pr comment list should parse");
1941        let Command::Pr(PrCmd::Comment(PrCommentCmd::List {
1942            number,
1943            comment_type,
1944            limit,
1945        })) = cli.cmd
1946        else {
1947            panic!("expected pr comment list");
1948        };
1949        assert_eq!(number, 42);
1950        assert_eq!(comment_type.as_deref(), Some("diff"));
1951        assert_eq!(limit.limit, 10);
1952    }
1953
1954    #[test]
1955    fn issue_comment_edit_parses_by_id_and_last() {
1956        let cli = Cli::try_parse_from([
1957            "gitee", "issue", "comment", "edit", "7", "-m", "fixed",
1958        ])
1959        .expect("issue comment edit by id should parse");
1960        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::Edit {
1961            target,
1962            last,
1963            body,
1964        })) = cli.cmd
1965        else {
1966            panic!("expected issue comment edit");
1967        };
1968        assert_eq!(target, "7");
1969        assert!(!last);
1970        assert_eq!(body.as_deref(), Some("fixed"));
1971
1972        let cli = Cli::try_parse_from([
1973            "gitee", "issue", "comment", "edit", "I88", "--last", "-m", "fixed",
1974        ])
1975        .expect("issue comment edit --last should parse");
1976        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::Edit {
1977            target,
1978            last,
1979            body,
1980        })) = cli.cmd
1981        else {
1982            panic!("expected issue comment edit --last");
1983        };
1984        assert_eq!(target, "I88");
1985        assert!(last);
1986        assert_eq!(body.as_deref(), Some("fixed"));
1987    }
1988
1989    #[test]
1990    fn pr_comment_edit_parses_by_id_and_last() {
1991        let cli = Cli::try_parse_from([
1992            "gitee", "pr", "comment", "edit", "42", "-m", "fixed",
1993        ])
1994        .expect("pr comment edit by id should parse");
1995        let Command::Pr(PrCmd::Comment(PrCommentCmd::Edit {
1996            target,
1997            last,
1998            body,
1999        })) = cli.cmd
2000        else {
2001            panic!("expected pr comment edit");
2002        };
2003        assert_eq!(target, 42);
2004        assert!(!last);
2005        assert_eq!(body.as_deref(), Some("fixed"));
2006
2007        let cli = Cli::try_parse_from([
2008            "gitee", "pr", "comment", "edit", "12", "--last", "-m", "fixed",
2009        ])
2010        .expect("pr comment edit --last should parse");
2011        let Command::Pr(PrCmd::Comment(PrCommentCmd::Edit {
2012            target,
2013            last,
2014            body,
2015        })) = cli.cmd
2016        else {
2017            panic!("expected pr comment edit --last");
2018        };
2019        assert_eq!(target, 12);
2020        assert!(last);
2021        assert_eq!(body.as_deref(), Some("fixed"));
2022    }
2023
2024    #[test]
2025    fn issue_comment_delete_parses_by_id_and_last() {
2026        let cli = Cli::try_parse_from(["gitee", "issue", "comment", "delete", "7", "--yes"])
2027            .expect("issue comment delete by id should parse");
2028        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::Delete {
2029            target,
2030            last,
2031            yes,
2032        })) = cli.cmd
2033        else {
2034            panic!("expected issue comment delete");
2035        };
2036        assert_eq!(target, "7");
2037        assert!(!last);
2038        assert!(yes);
2039
2040        let cli = Cli::try_parse_from([
2041            "gitee", "issue", "comment", "delete", "I88", "--last", "-y",
2042        ])
2043        .expect("issue comment delete --last should parse");
2044        let Command::Issue(IssueCmd::Comment(IssueCommentCmd::Delete {
2045            target,
2046            last,
2047            yes,
2048        })) = cli.cmd
2049        else {
2050            panic!("expected issue comment delete --last");
2051        };
2052        assert_eq!(target, "I88");
2053        assert!(last);
2054        assert!(yes);
2055    }
2056
2057    #[test]
2058    fn pr_comment_delete_parses_by_id_and_last() {
2059        let cli = Cli::try_parse_from(["gitee", "pr", "comment", "delete", "42", "--yes"])
2060            .expect("pr comment delete by id should parse");
2061        let Command::Pr(PrCmd::Comment(PrCommentCmd::Delete {
2062            target,
2063            last,
2064            yes,
2065        })) = cli.cmd
2066        else {
2067            panic!("expected pr comment delete");
2068        };
2069        assert_eq!(target, 42);
2070        assert!(!last);
2071        assert!(yes);
2072
2073        let cli = Cli::try_parse_from([
2074            "gitee", "pr", "comment", "delete", "12", "--last", "-y",
2075        ])
2076        .expect("pr comment delete --last should parse");
2077        let Command::Pr(PrCmd::Comment(PrCommentCmd::Delete {
2078            target,
2079            last,
2080            yes,
2081        })) = cli.cmd
2082        else {
2083            panic!("expected pr comment delete --last");
2084        };
2085        assert_eq!(target, 12);
2086        assert!(last);
2087        assert!(yes);
2088    }
2089
2090    #[test]
2091    fn issue_label_add_remove_list_parse() {
2092        let cli = Cli::try_parse_from([
2093            "gitee", "issue", "label", "add", "I88", "bug", "ui",
2094        ])
2095        .expect("issue label add should parse");
2096        let Command::Issue(IssueCmd::Label(IssueLabelCmd::Add { number, labels })) = cli.cmd
2097        else {
2098            panic!("expected issue label add");
2099        };
2100        assert_eq!(number, "I88");
2101        assert_eq!(labels, vec!["bug".to_string(), "ui".to_string()]);
2102
2103        let cli = Cli::try_parse_from(["gitee", "issue", "label", "remove", "I88", "bug"])
2104            .expect("issue label remove should parse");
2105        let Command::Issue(IssueCmd::Label(IssueLabelCmd::Remove { number, labels })) = cli.cmd
2106        else {
2107            panic!("expected issue label remove");
2108        };
2109        assert_eq!(number, "I88");
2110        assert_eq!(labels, vec!["bug".to_string()]);
2111
2112        let cli = Cli::try_parse_from(["gitee", "issue", "label", "list", "I88"])
2113            .expect("issue label list should parse");
2114        let Command::Issue(IssueCmd::Label(IssueLabelCmd::List { number })) = cli.cmd else {
2115            panic!("expected issue label list");
2116        };
2117        assert_eq!(number, "I88");
2118    }
2119
2120    #[test]
2121    fn pr_label_add_remove_list_parse() {
2122        let cli = Cli::try_parse_from(["gitee", "pr", "label", "add", "12", "bug", "ui"])
2123            .expect("pr label add should parse");
2124        let Command::Pr(PrCmd::Label(PrLabelCmd::Add { number, labels })) = cli.cmd else {
2125            panic!("expected pr label add");
2126        };
2127        assert_eq!(number, 12);
2128        assert_eq!(labels, vec!["bug".to_string(), "ui".to_string()]);
2129
2130        let cli = Cli::try_parse_from(["gitee", "pr", "label", "remove", "12", "bug"])
2131            .expect("pr label remove should parse");
2132        let Command::Pr(PrCmd::Label(PrLabelCmd::Remove { number, labels })) = cli.cmd else {
2133            panic!("expected pr label remove");
2134        };
2135        assert_eq!(number, 12);
2136        assert_eq!(labels, vec!["bug".to_string()]);
2137
2138        let cli = Cli::try_parse_from(["gitee", "pr", "label", "list", "12"])
2139            .expect("pr label list should parse");
2140        let Command::Pr(PrCmd::Label(PrLabelCmd::List { number })) = cli.cmd else {
2141            panic!("expected pr label list");
2142        };
2143        assert_eq!(number, 12);
2144    }
2145
2146    #[test]
2147    fn pr_commits_parse() {
2148        let cli = Cli::try_parse_from(["gitee", "pr", "commits", "12"])
2149            .expect("pr commits should parse");
2150        let Command::Pr(PrCmd::Commits { number, limit }) = cli.cmd else {
2151            panic!("expected pr commits");
2152        };
2153        assert_eq!(number, 12);
2154        assert_eq!(limit.limit, 30);
2155
2156        let cli = Cli::try_parse_from(["gitee", "pr", "commits", "12", "--limit", "5"])
2157            .expect("pr commits --limit should parse");
2158        let Command::Pr(PrCmd::Commits { number, limit }) = cli.cmd else {
2159            panic!("expected pr commits with limit");
2160        };
2161        assert_eq!(number, 12);
2162        assert_eq!(limit.limit, 5);
2163    }
2164
2165    #[test]
2166    fn pr_assignee_add_remove_list_parse() {
2167        let cli = Cli::try_parse_from(["gitee", "pr", "assignee", "add", "12", "dev1", "dev2"])
2168            .expect("pr assignee add should parse");
2169        let Command::Pr(PrCmd::Assignee(PrAssigneeCmd::Add { number, users })) = cli.cmd else {
2170            panic!("expected pr assignee add");
2171        };
2172        assert_eq!(number, 12);
2173        assert_eq!(users, vec!["dev1".to_string(), "dev2".to_string()]);
2174
2175        let cli = Cli::try_parse_from(["gitee", "pr", "assignee", "remove", "12", "dev1"])
2176            .expect("pr assignee remove should parse");
2177        let Command::Pr(PrCmd::Assignee(PrAssigneeCmd::Remove { number, users })) = cli.cmd else {
2178            panic!("expected pr assignee remove");
2179        };
2180        assert_eq!(number, 12);
2181        assert_eq!(users, vec!["dev1".to_string()]);
2182
2183        let cli = Cli::try_parse_from(["gitee", "pr", "assignee", "list", "12"])
2184            .expect("pr assignee list should parse");
2185        let Command::Pr(PrCmd::Assignee(PrAssigneeCmd::List { number })) = cli.cmd else {
2186            panic!("expected pr assignee list");
2187        };
2188        assert_eq!(number, 12);
2189    }
2190
2191    #[test]
2192    fn pr_tester_add_remove_list_parse() {
2193        let cli = Cli::try_parse_from(["gitee", "pr", "tester", "add", "12", "qa1", "qa2"])
2194            .expect("pr tester add should parse");
2195        let Command::Pr(PrCmd::Tester(PrTesterCmd::Add { number, users })) = cli.cmd else {
2196            panic!("expected pr tester add");
2197        };
2198        assert_eq!(number, 12);
2199        assert_eq!(users, vec!["qa1".to_string(), "qa2".to_string()]);
2200
2201        let cli = Cli::try_parse_from(["gitee", "pr", "tester", "remove", "12", "qa1"])
2202            .expect("pr tester remove should parse");
2203        let Command::Pr(PrCmd::Tester(PrTesterCmd::Remove { number, users })) = cli.cmd else {
2204            panic!("expected pr tester remove");
2205        };
2206        assert_eq!(number, 12);
2207        assert_eq!(users, vec!["qa1".to_string()]);
2208
2209        let cli = Cli::try_parse_from(["gitee", "pr", "tester", "list", "12"])
2210            .expect("pr tester list should parse");
2211        let Command::Pr(PrCmd::Tester(PrTesterCmd::List { number })) = cli.cmd else {
2212            panic!("expected pr tester list");
2213        };
2214        assert_eq!(number, 12);
2215    }
2216}