Skip to main content

gitee_cli_rs/cmd/
mod.rs

1use std::cell::OnceCell;
2use std::str::FromStr;
3
4use clap::CommandFactory;
5use clap_complete::{generate, Shell};
6
7use crate::api::client::Client;
8use crate::cli::{Cli, Command};
9use crate::config::Config;
10use crate::error::{GiteeError, Result};
11use crate::models::UserBasic;
12use crate::out::Output;
13use crate::repo::Repo;
14
15pub mod api;
16pub mod alias;
17pub mod extension;
18pub mod auth;
19pub mod browse;
20pub mod collaborator;
21pub mod config_cmd;
22pub mod gist;
23pub mod interactive;
24pub mod issue;
25pub mod label;
26pub mod org;
27pub mod pr;
28pub mod milestone;
29pub mod release;
30pub mod search;
31pub mod ssh_key;
32pub mod status;
33pub mod repo;
34pub mod webhook;
35
36pub struct Ctx {
37    pub client: Client,
38    pub out: Output,
39    pub host: String,
40    /// True when `--preview` was passed: mutating verbs print intent and exit 0
41    /// without making the mutating HTTP call.
42    pub preview: bool,
43    repo_arg: Option<String>,
44    remote_arg: Option<String>,
45    repo: OnceCell<Repo>,
46    me: OnceCell<UserBasic>,
47}
48
49/// Format a `--preview` intent line consistently. Mutating verbs call this
50/// before doing any work when `ctx.preview` is set, then return `Ok(())`.
51pub fn preview_line(action: &str, details: &[(&str, &str)]) -> String {
52    let mut s = format!("would {action}");
53    if !details.is_empty() {
54        s.push_str(": ");
55        let parts: Vec<String> = details.iter().map(|(k, v)| format!("{k}={v}")).collect();
56        s.push_str(&parts.join(", "));
57    }
58    s
59}
60
61impl Ctx {
62    pub fn repo(&self) -> Result<&Repo> {
63        if let Some(r) = self.repo.get() {
64            return Ok(r);
65        }
66        let r = Repo::resolve(self.repo_arg.as_deref(), self.remote_arg.as_deref())?;
67        let _ = self.repo.set(r);
68        Ok(self.repo.get().expect("repo just initialized"))
69    }
70
71    pub fn repo_arg(&self) -> Option<&str> {
72        self.repo_arg.as_deref()
73    }
74
75    /// The authenticated user, fetched once per invocation and cached.
76    pub fn me(&self) -> Result<&UserBasic> {
77        if let Some(u) = self.me.get() {
78            return Ok(u);
79        }
80        let u = self.client.users().me()?;
81        let _ = self.me.set(u);
82        Ok(self.me.get().expect("user just initialized"))
83    }
84}
85
86pub fn run(cli: Cli) -> Result<()> {
87    match &cli.cmd {
88        Command::Auth(c) => auth::execute(c.clone(), &cli.host),
89        Command::Config(c) => {
90            let ctx = build_inner(&cli, false)?;
91            config_cmd::execute(&ctx, c.clone())
92        }
93        Command::Alias(c) => {
94            let ctx = build_inner(&cli, false)?;
95            alias::execute(&ctx, c.clone())
96        }
97        Command::Browse => {
98            let ctx = build_inner(&cli, false)?;
99            browse::execute(&ctx)
100        }
101        Command::Api(a) => {
102            let client = core(&cli)?;
103            api::execute(&client, a.clone())
104        }
105        Command::Gist(c) => {
106            let ctx = build(&cli)?;
107            gist::execute(&ctx, c.clone())
108        }
109        Command::Pr(c) => {
110            let require_auth = !matches!(c, crate::cli::PrCmd::View { web: true, .. });
111            let ctx = build_inner(&cli, require_auth)?;
112            pr::execute(&ctx, c.clone())
113        }
114        Command::Issue(c) => {
115            let require_auth = !matches!(c, crate::cli::IssueCmd::View { web: true, .. });
116            let ctx = build_inner(&cli, require_auth)?;
117            issue::execute(&ctx, c.clone())
118        }
119        Command::Search(c) => {
120            let ctx = build(&cli)?;
121            search::execute(&ctx, c.clone())
122        }
123        Command::Status { limit } => {
124            let ctx = build(&cli)?;
125            status::execute(&ctx, limit.clone())
126        }
127        Command::Release(c) => {
128            let require_auth = !matches!(c, crate::cli::ReleaseCmd::View { web: true, .. });
129            let ctx = build_inner(&cli, require_auth)?;
130            release::execute(&ctx, c.clone())
131        }
132        Command::Label(c) => {
133            let ctx = build(&cli)?;
134            label::execute(&ctx, c.clone())
135        }
136        Command::Repo(c) => {
137            let require_auth = !matches!(c, crate::cli::RepoCmd::View { web: true, .. });
138            let ctx = build_inner(&cli, require_auth)?;
139            repo::execute(&ctx, c.clone())
140        }
141        Command::Milestone(c) => {
142            let ctx = build(&cli)?;
143            milestone::execute(&ctx, c.clone())
144        }
145        Command::Org(c) => {
146            let ctx = build(&cli)?;
147            org::execute(&ctx, c.clone())
148        }
149        Command::SshKey(c) => {
150            let ctx = build(&cli)?;
151            ssh_key::execute(&ctx, c.clone())
152        }
153        Command::Collaborator(c) => {
154            let ctx = build(&cli)?;
155            collaborator::execute(&ctx, c.clone())
156        }
157        Command::Webhook(c) => {
158            let ctx = build(&cli)?;
159            webhook::execute(&ctx, c.clone())
160        }
161        Command::Extension(c) => {
162            let ctx = build_inner(&cli, false)?;
163            extension::execute(&ctx, c.clone())
164        }
165        Command::External(args) => {
166            let Some(name) = args.first().and_then(|s| s.to_str()) else {
167                return Err(crate::error::GiteeError::Usage(
168                    "extension command name required".into(),
169                ));
170            };
171            crate::extension::exec(name, &args[1..], &cli.host)
172        }
173        Command::Completions { shell } => completions(shell.clone()),
174    }
175}
176
177/// HTTP client with no repo resolution.
178fn core(cli: &Cli) -> Result<Client> {
179    core_inner(cli, true)
180}
181
182fn core_inner(cli: &Cli, require_auth: bool) -> Result<Client> {
183    let token = match Config::token(&cli.host) {
184        Ok(t) => t,
185        Err(GiteeError::NotLoggedIn) if !require_auth => String::new(),
186        Err(e) => return Err(e),
187    };
188    let mut client = Client::for_host(&cli.host, token);
189    client.set_debug(cli.debug);
190    Ok(client)
191}
192
193/// Guard a destructive operation behind explicit confirmation. `--yes` skips
194/// the prompt; an interactive terminal must type `yes`; anything else (piped
195/// stdin, no TTY) is a usage error, so scripts can't delete by accident.
196pub fn confirm(action: &str, yes: bool) -> Result<()> {
197    use std::io::IsTerminal;
198    if yes {
199        return Ok(());
200    }
201    if !std::io::stdin().is_terminal() {
202        return Err(GiteeError::Usage(format!(
203            "{action}: pass --yes to confirm (stdin is not a terminal)"
204        )));
205    }
206    eprintln!("{action}? Type 'yes' to confirm: ");
207    let mut line = String::new();
208    std::io::stdin().read_line(&mut line).ok();
209    if line.trim() == "yes" {
210        Ok(())
211    } else {
212        Err(GiteeError::Usage("aborted".into()))
213    }
214}
215
216/// Flatten repeatable, comma-splittable flag values (e.g. `--label a,b --label c`)
217/// into one comma-joined string; `None` when nothing was given.
218pub(crate) fn join_flags(values: &[String]) -> Option<String> {
219    let parts: Vec<&str> = values
220        .iter()
221        .flat_map(|v| v.split(','))
222        .map(str::trim)
223        .filter(|s| !s.is_empty())
224        .collect();
225    (!parts.is_empty()).then(|| parts.join(","))
226}
227
228/// Resolve a `--milestone` value: bare integers pass through; anything else is
229/// matched against the repo's milestone titles (one extra API call).
230pub(crate) fn resolve_milestone(ctx: &Ctx, repo: &Repo, id_or_title: &str) -> Result<i64> {
231    if let Ok(n) = id_or_title.trim().parse::<i64>() {
232        return Ok(n);
233    }
234    let list = ctx
235        .client
236        .repos()
237        .list_milestones(&repo.owner, &repo.name)?;
238    crate::models::Milestone::resolve(&list, id_or_title).ok_or_else(|| {
239        let known = list
240            .iter()
241            .map(|m| m.title.as_str())
242            .collect::<Vec<_>>()
243            .join(", ");
244        GiteeError::Usage(format!(
245            "no milestone titled '{id_or_title}' (available: {known})"
246        ))
247    })
248}
249
250/// Optional variant of [`resolve_milestone`]: `None` stays `None`.
251pub(crate) fn resolve_milestone_opt(
252    ctx: &Ctx,
253    repo: &Repo,
254    id_or_title: Option<&str>,
255) -> Result<Option<i64>> {
256    match id_or_title {
257        Some(m) => Ok(Some(resolve_milestone(ctx, repo, m)?)),
258        None => Ok(None),
259    }
260}
261
262fn build(cli: &Cli) -> Result<Ctx> {
263    build_inner(cli, true)
264}
265
266fn build_inner(cli: &Cli, require_auth: bool) -> Result<Ctx> {
267    Ok(Ctx {
268        client: core_inner(cli, require_auth)?,
269        out: Output {
270            json: cli.json.clone(),
271            jq: cli.jq.clone(),
272        },
273        host: cli.host.clone(),
274        preview: cli.preview,
275        repo_arg: cli.repo.clone(),
276        remote_arg: cli.remote.clone(),
277        repo: OnceCell::new(),
278        me: OnceCell::new(),
279    })
280}
281
282fn completions(shell: Option<String>) -> Result<()> {
283    let shell = match shell.as_deref() {
284        Some(s) => Shell::from_str(s).map_err(|_| {
285            GiteeError::Usage(format!(
286                "unknown shell '{s}'; use one of: bash, zsh, fish, powershell, elvish"
287            ))
288        })?,
289        None => detect_shell()?,
290    };
291    // Generate into a buffer first: clap_complete panics on write errors,
292    // and a closed pipe (`gitee completions bash | head`) must exit quietly.
293    let mut cmd: clap::Command = crate::cli::Cli::command();
294    let mut buf = Vec::new();
295    generate(shell, &mut cmd, "gitee", &mut buf);
296    use std::io::Write;
297    let mut out = std::io::stdout().lock();
298    match out.write_all(&buf).and_then(|()| out.flush()) {
299        Ok(()) => Ok(()),
300        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
301        Err(e) => Err(e.into()),
302    }
303}
304
305fn detect_shell() -> Result<Shell> {
306    let shell = std::env::var("SHELL").unwrap_or_default();
307    let name = shell.rsplit('/').next().unwrap_or("bash");
308    Shell::from_str(name).map_err(|_| {
309        GiteeError::Usage(format!(
310            "could not detect shell from $SHELL='{shell}'; pass it explicitly (bash|zsh|fish|...)"
311        ))
312    })
313}
314
315#[cfg(test)]
316mod auth_free_tests {
317    use super::*;
318    use crate::cli::Cli;
319
320    #[test]
321    fn builds_without_auth_for_local_commands() {
322        use clap::Parser;
323
324        let dir = std::env::temp_dir().join(format!(
325            "gitee-cli-authfree-{}-{}",
326            std::process::id(),
327            std::time::SystemTime::now()
328                .duration_since(std::time::UNIX_EPOCH)
329                .unwrap()
330                .as_nanos()
331        ));
332        let _ = std::fs::remove_dir_all(&dir);
333        std::fs::create_dir_all(&dir).unwrap();
334        std::fs::write(dir.join("config.json"), "{}
335").unwrap();
336        std::env::set_var("GITEE_CONFIG_DIR", &dir);
337        for args in ["gitee config list", "gitee alias list", "gitee browse"] {
338            let cli = Cli::try_parse_from(args.split_whitespace()).expect("parse");
339            build_inner(&cli, false).expect("build without auth");
340        }
341        std::env::remove_var("GITEE_CONFIG_DIR");
342        let _ = std::fs::remove_dir_all(&dir);
343    }
344}
345
346#[cfg(test)]
347mod flag_tests {
348    #[test]
349    fn join_flags_flattens_repeatable_and_comma_split() {
350        let v = vec!["a,b".to_string(), " c ".to_string()];
351        assert_eq!(super::join_flags(&v).as_deref(), Some("a,b,c"));
352    }
353
354    #[test]
355    fn join_flags_empty_is_none() {
356        assert_eq!(super::join_flags(&[]), None);
357        assert_eq!(super::join_flags(&["  ".to_string()]), None);
358    }
359
360    #[test]
361    fn preview_line_includes_action_and_keyed_details() {
362        let line = super::preview_line("close issue I88", &[("repo", "oschina/gitee-cli")]);
363        assert_eq!(line, "would close issue I88: repo=oschina/gitee-cli");
364    }
365
366    #[test]
367    fn preview_line_omits_details_when_empty() {
368        let line = super::preview_line("delete repo", &[]);
369        assert_eq!(line, "would delete repo");
370    }
371}
372
373
374#[cfg(test)]
375mod create_title_tests {
376    use super::*;
377    use crate::cli::{Cli, Command, IssueCmd, PrCmd};
378    use clap::Parser;
379
380    #[test]
381    fn issue_create_non_tty_missing_title_before_repo() {
382        let _env = crate::config::test_config_env_lock();
383        let prev_token = std::env::var_os("GITEE_TOKEN");
384        std::env::set_var("GITEE_TOKEN", "test-token");
385        let cli = Cli::try_parse_from(["gitee", "issue", "create"]).unwrap();
386        let ctx = build_inner(&cli, true).unwrap();
387        let Command::Issue(cmd) = cli.cmd else {
388            panic!("expected issue command");
389        };
390        let IssueCmd::Create { .. } = cmd else {
391            panic!("expected issue create");
392        };
393        let err = issue::execute(&ctx, cmd).unwrap_err();
394        assert!(err.to_string().contains("issue create needs --title"));
395        if let Some(t) = prev_token {
396            std::env::set_var("GITEE_TOKEN", t);
397        } else {
398            std::env::remove_var("GITEE_TOKEN");
399        }
400    }
401
402    #[test]
403    fn pr_create_non_tty_missing_title_before_repo() {
404        let _env = crate::config::test_config_env_lock();
405        let prev_token = std::env::var_os("GITEE_TOKEN");
406        std::env::set_var("GITEE_TOKEN", "test-token");
407        let cli = Cli::try_parse_from(["gitee", "pr", "create"]).unwrap();
408        let ctx = build_inner(&cli, true).unwrap();
409        let Command::Pr(cmd) = cli.cmd else {
410            panic!("expected pr command");
411        };
412        let PrCmd::Create { .. } = cmd else {
413            panic!("expected pr create");
414        };
415        let err = pr::execute(&ctx, cmd).unwrap_err();
416        assert!(err.to_string().contains("pr create needs --title"));
417        if let Some(t) = prev_token {
418            std::env::set_var("GITEE_TOKEN", t);
419        } else {
420            std::env::remove_var("GITEE_TOKEN");
421        }
422    }
423
424    /// `--preview` short-circuits issue create before any HTTP call: with
425    /// `--repo` supplied, `ctx.repo()` resolves without git/HTTP, and the
426    /// handler returns Ok after printing intent.
427    #[test]
428    fn issue_create_preview_prints_intent_without_http() {
429        let _env = crate::config::test_config_env_lock();
430        let prev_token = std::env::var_os("GITEE_TOKEN");
431        std::env::set_var("GITEE_TOKEN", "test-token");
432        let cli = Cli::try_parse_from([
433            "gitee",
434            "--repo",
435            "oschina/gitee-cli",
436            "--preview",
437            "issue",
438            "create",
439            "--title",
440            "T",
441        ])
442        .unwrap();
443        assert!(cli.preview);
444        let ctx = build_inner(&cli, true).unwrap();
445        let Command::Issue(cmd) = cli.cmd else {
446            panic!("expected issue command");
447        };
448        let IssueCmd::Create { .. } = cmd else {
449            panic!("expected issue create");
450        };
451        // No HTTP server is running; if --preview failed to short-circuit,
452        // execute would error trying to reach the real gitee.com.
453        issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
454        if let Some(t) = prev_token {
455            std::env::set_var("GITEE_TOKEN", t);
456        } else {
457            std::env::remove_var("GITEE_TOKEN");
458        }
459    }
460
461    #[test]
462    fn issue_close_preview_prints_intent_without_http() {
463        let _env = crate::config::test_config_env_lock();
464        let prev_token = std::env::var_os("GITEE_TOKEN");
465        std::env::set_var("GITEE_TOKEN", "test-token");
466        let cli = Cli::try_parse_from([
467            "gitee",
468            "--repo",
469            "oschina/gitee-cli",
470            "--preview",
471            "issue",
472            "close",
473            "I88",
474        ])
475        .unwrap();
476        let ctx = build_inner(&cli, true).unwrap();
477        let Command::Issue(cmd) = cli.cmd else {
478            panic!("expected issue command");
479        };
480        let IssueCmd::Close { .. } = cmd else {
481            panic!("expected issue close");
482        };
483        issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
484        if let Some(t) = prev_token {
485            std::env::set_var("GITEE_TOKEN", t);
486        } else {
487            std::env::remove_var("GITEE_TOKEN");
488        }
489    }
490
491    #[test]
492    fn pr_create_preview_prints_intent_without_http() {
493        let _env = crate::config::test_config_env_lock();
494        let prev_token = std::env::var_os("GITEE_TOKEN");
495        std::env::set_var("GITEE_TOKEN", "test-token");
496        let cli = Cli::try_parse_from([
497            "gitee",
498            "--repo",
499            "oschina/gitee-cli",
500            "--preview",
501            "pr",
502            "create",
503            "--title",
504            "T",
505            "--head",
506            "y",
507        ])
508        .unwrap();
509        let ctx = build_inner(&cli, true).unwrap();
510        let Command::Pr(cmd) = cli.cmd else {
511            panic!("expected pr command");
512        };
513        let PrCmd::Create { .. } = cmd else {
514            panic!("expected pr create");
515        };
516        pr::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
517        if let Some(t) = prev_token {
518            std::env::set_var("GITEE_TOKEN", t);
519        } else {
520            std::env::remove_var("GITEE_TOKEN");
521        }
522    }
523
524    /// `--preview` short-circuits issue comment create before any HTTP call.
525    #[test]
526    fn issue_comment_create_preview_prints_intent_without_http() {
527        let _env = crate::config::test_config_env_lock();
528        let prev_token = std::env::var_os("GITEE_TOKEN");
529        std::env::set_var("GITEE_TOKEN", "test-token");
530        let cli = Cli::try_parse_from([
531            "gitee",
532            "--repo",
533            "oschina/gitee-cli",
534            "--preview",
535            "issue",
536            "comment",
537            "create",
538            "I88",
539            "-m",
540            "looking into it",
541        ])
542        .unwrap();
543        assert!(cli.preview);
544        let ctx = build_inner(&cli, true).unwrap();
545        let Command::Issue(cmd) = cli.cmd else {
546            panic!("expected issue command");
547        };
548        let IssueCmd::Comment(crate::cli::IssueCommentCmd::Create { .. }) = cmd else {
549            panic!("expected issue comment create");
550        };
551        issue::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
552        if let Some(t) = prev_token {
553            std::env::set_var("GITEE_TOKEN", t);
554        } else {
555            std::env::remove_var("GITEE_TOKEN");
556        }
557    }
558
559    /// `--preview` short-circuits pr comment create before any HTTP call.
560    #[test]
561    fn pr_comment_create_preview_prints_intent_without_http() {
562        let _env = crate::config::test_config_env_lock();
563        let prev_token = std::env::var_os("GITEE_TOKEN");
564        std::env::set_var("GITEE_TOKEN", "test-token");
565        let cli = Cli::try_parse_from([
566            "gitee",
567            "--repo",
568            "oschina/gitee-cli",
569            "--preview",
570            "pr",
571            "comment",
572            "create",
573            "42",
574            "-m",
575            "LGTM",
576        ])
577        .unwrap();
578        assert!(cli.preview);
579        let ctx = build_inner(&cli, true).unwrap();
580        let Command::Pr(cmd) = cli.cmd else {
581            panic!("expected pr command");
582        };
583        let PrCmd::Comment(crate::cli::PrCommentCmd::Create { .. }) = cmd else {
584            panic!("expected pr comment create");
585        };
586        pr::execute(&ctx, cmd).expect("preview should short-circuit without HTTP");
587        if let Some(t) = prev_token {
588            std::env::set_var("GITEE_TOKEN", t);
589        } else {
590            std::env::remove_var("GITEE_TOKEN");
591        }
592    }
593}