Skip to main content

vcs_gitea/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-gitea` — automate Gitea (and Forgejo) from Rust by driving the `tea` CLI.
4//!
5//! It shells out to the installed `tea` binary, asks each command for
6//! `--output json`, and deserializes that into typed values — so you get *tea's
7//! own* auth, config, and instance handling, not a reimplementation of the Gitea
8//! API. Async throughout, structured errors, and mockable. Every command runs
9//! inside an OS **job** (via [`processkit`]) so a `tea` subprocess tree can never
10//! be orphaned, and honours an optional per-client [timeout](Gitea::default_timeout).
11//!
12//! # The surface
13//!
14//! - **[`GiteaApi`]** — the object-safe trait every operation lives on. Depend on
15//!   `&dyn GiteaApi` (or generically on `impl GiteaApi`) so a test can swap the
16//!   real client for a double. The repo-scoped methods take the working directory
17//!   as the first argument and return typed results ([`PullRequest`], [`Issue`],
18//!   [`Release`]) or a structured [`Error`]; unmodelled `tea` commands go through
19//!   [`run`](GiteaApi::run).
20//! - **[`Gitea`]** — the real client. [`Gitea::new`] uses the job-backed runner;
21//!   [`Gitea::with_runner`] injects a fake one for tests. It is generic over the
22//!   [`ProcessRunner`] seam, defaulting to the production runner.
23//! - **[`GiteaAt`]** — a cwd-bound view ([`Gitea::at`]) whose repo-scoped methods
24//!   drop the leading `dir`, so `tea.at(dir).pr_list()` reads as
25//!   `tea.pr_list(dir)` — handy when one client drives one checkout.
26//! - **Specs & enums** — [`PrCreate`] (`#[non_exhaustive]`, a constructor plus
27//!   chained `.head` / `.base` setters named after the flags they emit) and
28//!   [`MergeStrategy`] (`Merge` / `Squash` / `Rebase` → `tea pr merge --style`).
29//!
30//! The exposed operations are the **lean lifecycle** `tea` actually supports:
31//! auth ([`auth_status`](GiteaApi::auth_status)), the PR lifecycle
32//! ([list](GiteaApi::pr_list) / [view](GiteaApi::pr_view) /
33//! [create](GiteaApi::pr_create) / [merge](GiteaApi::pr_merge) /
34//! [close](GiteaApi::pr_close)), issues
35//! ([list](GiteaApi::issue_list) / [view](GiteaApi::issue_view) /
36//! [create](GiteaApi::issue_create)), and [release listing](GiteaApi::release_list).
37//! It is deliberately narrower than
38//! [`vcs-github`](https://crates.io/crates/vcs-github) /
39//! [`vcs-gitlab`](https://crates.io/crates/vcs-gitlab): `tea` has **no** single-PR
40//! `view`, **no** current-repo view, **no** draft toggle, **no** PR-checks
41//! command, and **no** single-release view (`tea releases` ignores any positional
42//! and always lists), so those operations are simply absent here (the
43//! [`vcs-forge`](https://crates.io/crates/vcs-forge) facade reports them
44//! `Unsupported` for the Gitea backend). [`pr_view`](GiteaApi::pr_view) is
45//! synthesized by listing with `--state all` and filtering by number;
46//! [`issue_view`](GiteaApi::issue_view), by contrast, is a first-class
47//! `tea issues <index>`.
48//!
49//! One shape caveat: `tea`'s `--output json` is **not** the Gitea REST shape. Its
50//! *list* commands emit tea's print-*table* — a JSON array of string-maps whose
51//! keys are snake-cased column headers and whose values are **all strings** (no
52//! `html_url`, no nested branch objects, no typed bools); we pick columns with
53//! `--fields`. Its *detail* view (`issues <n>`) is a separate *typed* object. The
54//! parsers model both (the `#[ignore]` real-`tea` tests in `tests/cli.rs` are the
55//! contract check).
56//!
57//! # Recipes
58//!
59//! Read state — depend on the trait so the same code takes a real client or a mock:
60//!
61//! ```no_run
62//! use std::path::Path;
63//! use vcs_gitea::{Gitea, GiteaApi};
64//! # async fn demo() -> Result<(), processkit::Error> {
65//! let tea = Gitea::new();
66//! let repo = Path::new(".");
67//! let authed = tea.auth_status().await?;             // any login configured?
68//! for pr in tea.pr_list(repo).await? {               // up to 100 open PRs
69//!     println!("#{} [{}] {}", pr.number, pr.state, pr.title);
70//! }
71//! # let _ = authed; Ok(()) }
72//! ```
73//!
74//! Drive the PR lifecycle — `pr_create` takes the [`PrCreate`] spec; merge picks a
75//! [`MergeStrategy`]:
76//!
77//! ```no_run
78//! use std::path::Path;
79//! use vcs_gitea::{Gitea, GiteaApi, MergeStrategy, PrCreate};
80//! # async fn demo(tea: &Gitea, repo: &Path) -> Result<(), processkit::Error> {
81//! tea.pr_create(repo, PrCreate::new("Add streaming", "Implements …")
82//!         .head("feat/streaming").base("main")).await?;
83//! tea.pr_merge(repo, 7, MergeStrategy::Squash).await?;
84//! # Ok(()) }
85//! ```
86//!
87//! # Testing
88//!
89//! Two seams: enable the **`mock`** feature for a `mockall`-generated
90//! `MockGiteaApi` (stub whole methods), or inject a
91//! [`ScriptedRunner`](processkit::ScriptedRunner) with [`Gitea::with_runner`] to
92//! exercise the *real* argv-building and JSON parsing against canned output. The
93//! cross-cutting testing patterns live in
94//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
95//!
96//! # In-depth guide
97//!
98//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
99//! from `docs/`. See the [`guide`] module.
100
101use std::path::Path;
102
103use processkit::ProcessRunner;
104// Re-export the processkit types in this crate's public API (also brings
105// `Error`/`Result`/`ProcessResult` into scope here).
106pub use processkit::{Error, ProcessResult, Result};
107// Re-exported under the `cancellation` feature so a consumer can name the token
108// for `default_cancel_on` without taking a direct `processkit` dependency.
109#[cfg(feature = "cancellation")]
110#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
111pub use processkit::CancellationToken;
112
113mod parse;
114pub use parse::{Issue, PullRequest, Release};
115
116/// Options for [`GiteaApi::pr_create`] (`tea pr create`).
117///
118/// `#[non_exhaustive]`, so build it through [`PrCreate::new`] and the chained
119/// setters rather than a struct literal.
120#[derive(Debug, Clone)]
121#[non_exhaustive]
122pub struct PrCreate {
123    /// The PR title (`--title`).
124    pub title: String,
125    /// The PR description (`--description`).
126    pub body: String,
127    /// The source branch (`--head`); `None` = the current branch.
128    pub head: Option<String>,
129    /// The target branch (`--base`); `None` = the repo default.
130    pub base: Option<String>,
131}
132
133impl PrCreate {
134    /// A PR with `title` and `body`, head/base left to tea's defaults
135    /// (current branch → repo default).
136    pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
137        Self {
138            title: title.into(),
139            body: body.into(),
140            head: None,
141            base: None,
142        }
143    }
144
145    /// Set the source branch (`--head`) instead of the current branch.
146    pub fn head(mut self, head: impl Into<String>) -> Self {
147        self.head = Some(head.into());
148        self
149    }
150
151    /// Set the target branch (`--base`) instead of the repo default.
152    pub fn base(mut self, base: impl Into<String>) -> Self {
153        self.base = Some(base.into());
154        self
155    }
156}
157
158/// Name of the underlying CLI binary this crate drives.
159///
160/// Note on injection safety: like `vcs-gitlab`, the lean surface has **no bare
161/// positional string slot** for a caller value — PR numbers are `u64`, the
162/// title/body/branch arguments ride in flag-VALUE positions, and `run` is the
163/// caller-owns-the-argv escape hatch. So there is nothing here to guard with
164/// `vcs_cli_support::reject_flag_like`.
165pub const BINARY: &str = "tea";
166
167// tea's `list` commands serialize a print-table whose columns are chosen with
168// `--fields`. We request exactly the columns the parsers read; every value comes
169// back as a JSON string (see `parse.rs`). These names are validated by tea
170// against its `PullFields`/`IssueFields` lists — keep them in that set.
171const PR_FIELDS: &str = "index,title,state,head,base,url";
172const ISSUE_FIELDS: &str = "index,title,state,body,url";
173
174/// How [`GiteaApi::pr_merge`] merges the PR — maps to `tea pr merge --style`
175/// (Gitea's default is a merge commit).
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum MergeStrategy {
179    /// A merge commit (`--style merge`).
180    Merge,
181    /// Squash the commits into one (`--style squash`).
182    Squash,
183    /// Rebase the source onto the target (`--style rebase`).
184    Rebase,
185}
186
187impl MergeStrategy {
188    /// The `tea pr merge --style` value for this strategy.
189    fn style(self) -> &'static str {
190        match self {
191            MergeStrategy::Merge => "merge",
192            MergeStrategy::Squash => "squash",
193            MergeStrategy::Rebase => "rebase",
194        }
195    }
196}
197
198/// The Gitea operations this crate exposes — the interface consumers code
199/// against and mock in tests. The **lean PR lifecycle** `tea` supports; reach
200/// unmodelled `tea` commands through [`run`](GiteaApi::run).
201#[cfg_attr(feature = "mock", mockall::automock)]
202#[async_trait::async_trait]
203pub trait GiteaApi: Send + Sync {
204    /// Run `tea <args>`, returning trimmed stdout (throws on a non-zero exit).
205    async fn run(&self, args: &[String]) -> Result<String>;
206    /// Like [`GiteaApi::run`] but never errors on a non-zero exit — returns the
207    /// captured [`ProcessResult`].
208    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
209    /// Installed Gitea CLI version (`tea --version`).
210    async fn version(&self) -> Result<String>;
211    /// Whether at least one login is configured (`tea login list --output json`
212    /// is a non-empty array). `tea` has no per-instance `auth status`, so this is
213    /// the closest "are we logged in" signal. Must not error on an unusual
214    /// outcome: a non-zero exit (e.g. no config file yet) reads as `false`, the
215    /// same as an empty array; only a spawn failure or timeout errors.
216    async fn auth_status(&self) -> Result<bool>;
217    /// Open pull requests for `dir` (`tea pr list --limit 100 --output json`).
218    /// Returns up to 100 open PRs; use [`run`](GiteaApi::run) for more.
219    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>>;
220    /// A single pull request by number. `tea` has no single-PR view, so this
221    /// **lists** (`tea pr list --state all --limit 999 --output json`) and filters
222    /// by number; a missing number is an [`Error::Parse`]. The high `--limit`
223    /// guards against a false "not found", but PRs beyond the first 999 are still
224    /// not found.
225    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest>;
226    /// Open a pull request, returning the command's output (`tea pr create`).
227    /// Unlike `gh`/`glab`, `tea` prints a textual summary on success, **not** the
228    /// new PR's URL (it has no `--output`/`--fields` flag to shape create output),
229    /// so do not parse this as a URL. The [`PrCreate`] spec carries the title,
230    /// body, and the optional head (`None` = the current branch) and base
231    /// (`None` = the repo default) branches.
232    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String>;
233    /// Merge a pull request (`tea pr merge <number> --style merge|rebase|squash`)
234    /// — see [`MergeStrategy`].
235    async fn pr_merge(&self, dir: &Path, number: u64, strategy: MergeStrategy) -> Result<()>;
236    /// Close a pull request without merging (`tea pr close <number>`).
237    async fn pr_close(&self, dir: &Path, number: u64) -> Result<()>;
238    /// Open issues for `dir` (`tea issues list --limit 100 --output json`).
239    /// Returns up to 100 open issues; use [`run`](GiteaApi::run) for more.
240    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>>;
241    /// A single issue by number. Unlike PRs, `tea` *does* have a single-issue
242    /// view — `tea issues <number>` (the bare index form), here run as
243    /// `tea issues <number> --output json`, deserializing one object rather than
244    /// listing and filtering.
245    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue>;
246    /// Open an issue, returning the command's output (`tea issues create
247    /// --title <t> --description <d>`). Like [`pr_create`](GiteaApi::pr_create),
248    /// `tea` prints a textual summary of the new issue (and, on the final line,
249    /// its URL) — there is no `--output`/`--fields` flag to shape create output —
250    /// so this returns the trimmed stdout verbatim rather than a parsed URL.
251    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String>;
252    /// Releases for `dir` (`tea releases list --limit 100 --output json`).
253    /// Returns up to 100 releases; use [`run`](GiteaApi::run) for more.
254    ///
255    /// There is intentionally no `release_view`: `tea releases` takes no
256    /// positional and always lists, so a single-release-by-tag view does not
257    /// exist in `tea` (the [`vcs-forge`](https://crates.io/crates/vcs-forge)
258    /// facade reports it `Unsupported`).
259    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>>;
260}
261
262processkit::cli_client!(
263    /// The real Gitea client. Generic over the [`ProcessRunner`] so tests can
264    /// inject a fake process executor; `Gitea::new()` uses the real job-backed
265    /// runner.
266    pub struct Gitea => BINARY
267);
268
269#[async_trait::async_trait]
270impl<R: ProcessRunner> GiteaApi for Gitea<R> {
271    async fn run(&self, args: &[String]) -> Result<String> {
272        self.core.run(self.core.command(args)).await
273    }
274
275    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
276        self.core.output(self.core.command(args)).await
277    }
278
279    async fn version(&self) -> Result<String> {
280        self.core.run(self.core.command(["--version"])).await
281    }
282
283    async fn auth_status(&self) -> Result<bool> {
284        // `tea login list --output json` is a global (non-repo) command that
285        // yields the configured logins as a JSON array; non-empty ⇒ logged in.
286        // `output` (not `run`) so a NON-ZERO exit — e.g. tea erroring because no
287        // config file exists yet — reads as "not logged in" rather than surfacing
288        // as an error; a spawn failure or timeout still errors via `ensure_success`.
289        let res = self
290            .core
291            .output(self.core.command(["login", "list", "--output", "json"]))
292            .await?;
293        if res.code() != Some(0) {
294            // A timeout / signal-kill (no exit code) is a genuine failure;
295            // `ensure_success` surfaces it as `Error::Timeout`/IO. A plain
296            // non-zero exit, however, just means "no logins" → false.
297            if res.code().is_none() {
298                res.ensure_success()?;
299            }
300            return Ok(false);
301        }
302        let json = res.stdout().trim();
303        // Treat empty output as "no logins" rather than a parse error — some tea
304        // builds print nothing (not `[]`) when none are configured.
305        if json.is_empty() {
306            return Ok(false);
307        }
308        let logins: Vec<serde_json::Value> = parse::from_json(json)?;
309        Ok(!logins.is_empty())
310    }
311
312    async fn pr_list(&self, dir: &Path) -> Result<Vec<PullRequest>> {
313        // `--limit 100` overrides tea's default page size (30), which would
314        // otherwise silently truncate the list. `--fields` selects exactly the
315        // table columns we parse — tea's default field set omits `head`/`base`/
316        // `url`, so without this the branches and URL would always be empty.
317        self.core
318            .try_parse(
319                self.core.command_in(
320                    dir,
321                    [
322                        "pr", "list", "--limit", "100", "--fields", PR_FIELDS, "--output", "json",
323                    ],
324                ),
325                parse::parse_pr_list,
326            )
327            .await
328    }
329
330    async fn pr_view(&self, dir: &Path, number: u64) -> Result<PullRequest> {
331        // `tea` has no single-PR view; list all states and filter by number. A
332        // high `--limit` is essential here: without it, tea's default page size
333        // (30) would make any PR past the first page a false "not found".
334        // `--fields` selects the columns we parse (see `pr_list`).
335        let prs = self
336            .core
337            .try_parse(
338                self.core.command_in(
339                    dir,
340                    [
341                        "pr", "list", "--state", "all", "--limit", "999", "--fields", PR_FIELDS,
342                        "--output", "json",
343                    ],
344                ),
345                parse::parse_pr_list,
346            )
347            .await?;
348        prs.into_iter()
349            .find(|pr| pr.number == number)
350            .ok_or_else(|| Error::Parse {
351                program: BINARY.to_string(),
352                message: format!("no pull request #{number} in `tea pr list`"),
353            })
354    }
355
356    async fn pr_create(&self, dir: &Path, spec: PrCreate) -> Result<String> {
357        let mut args = vec![
358            "pr",
359            "create",
360            "--title",
361            spec.title.as_str(),
362            "--description",
363            spec.body.as_str(),
364        ];
365        if let Some(head) = spec.head.as_deref() {
366            args.push("--head");
367            args.push(head);
368        }
369        if let Some(base) = spec.base.as_deref() {
370            args.push("--base");
371            args.push(base);
372        }
373        self.core.run(self.core.command_in(dir, args)).await
374    }
375
376    async fn pr_merge(&self, dir: &Path, number: u64, strategy: MergeStrategy) -> Result<()> {
377        let n = number.to_string();
378        self.core
379            .run_unit(self.core.command_in(
380                dir,
381                ["pr", "merge", n.as_str(), "--style", strategy.style()],
382            ))
383            .await
384    }
385
386    async fn pr_close(&self, dir: &Path, number: u64) -> Result<()> {
387        let n = number.to_string();
388        self.core
389            .run_unit(self.core.command_in(dir, ["pr", "close", n.as_str()]))
390            .await
391    }
392
393    async fn issue_list(&self, dir: &Path) -> Result<Vec<Issue>> {
394        // `--limit 100` overrides tea's default page size (30), mirroring
395        // `pr_list`, so the list is not silently truncated. `--fields` selects
396        // the columns we parse — tea's default issue fields omit `body`/`url`,
397        // so without this both would always come back empty.
398        self.core
399            .try_parse(
400                self.core.command_in(
401                    dir,
402                    [
403                        "issues",
404                        "list",
405                        "--limit",
406                        "100",
407                        "--fields",
408                        ISSUE_FIELDS,
409                        "--output",
410                        "json",
411                    ],
412                ),
413                parse::parse_issue_list,
414            )
415            .await
416    }
417
418    async fn issue_view(&self, dir: &Path, number: u64) -> Result<Issue> {
419        // `tea issues <index>` is the documented bare-index single-issue view;
420        // `--output json` yields one object. `number` is a `u64`, so it can
421        // never look like a flag — nothing to guard with `reject_flag_like`.
422        let n = number.to_string();
423        self.core
424            .try_parse(
425                self.core
426                    .command_in(dir, ["issues", n.as_str(), "--output", "json"]),
427                parse::parse_issue,
428            )
429            .await
430    }
431
432    async fn issue_create(&self, dir: &Path, title: &str, body: &str) -> Result<String> {
433        self.core
434            .run(self.core.command_in(
435                dir,
436                ["issues", "create", "--title", title, "--description", body],
437            ))
438            .await
439    }
440
441    async fn release_list(&self, dir: &Path) -> Result<Vec<Release>> {
442        // `--limit 100` overrides tea's default page size (30); `tea releases`
443        // has no `--state`, so this returns the most recent 100 releases.
444        self.core
445            .try_parse(
446                self.core.command_in(
447                    dir,
448                    ["releases", "list", "--limit", "100", "--output", "json"],
449                ),
450                parse::parse_release_list,
451            )
452            .await
453    }
454}
455
456impl<R: ProcessRunner> Gitea<R> {
457    /// Run `tea <args>` over string slices — `tea.run_args(&["pr", "list"])`
458    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
459    /// trait), so it can take `&[&str]`; forwards to the same path as
460    /// [`GiteaApi::run`].
461    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
462        self.core.run(self.core.command(args)).await
463    }
464
465    /// Like [`run_args`](Gitea::run_args) but never errors on a non-zero exit
466    /// (mirrors [`GiteaApi::run_raw`]).
467    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
468        self.core.output(self.core.command(args)).await
469    }
470
471    /// Bind a working directory, so the repo-scoped methods omit that argument:
472    /// `tea.at(dir).pr_list()` runs [`pr_list`](GiteaApi::pr_list) against `dir`.
473    pub fn at<'a>(&'a self, dir: &'a Path) -> GiteaAt<'a, R> {
474        GiteaAt { tea: self, dir }
475    }
476}
477
478/// A [`Gitea`] client with a working directory bound, so its repo-scoped methods
479/// drop the leading `dir` argument (`tea.at(dir).pr_list()`). Construct one with
480/// [`Gitea::at`].
481pub struct GiteaAt<'a, R: ProcessRunner = processkit::JobRunner> {
482    tea: &'a Gitea<R>,
483    dir: &'a Path,
484}
485
486// Hand-written rather than derived: holding only references, the view is `Copy`
487// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
488// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the handle.
489impl<R: ProcessRunner> Clone for GiteaAt<'_, R> {
490    fn clone(&self) -> Self {
491        *self
492    }
493}
494impl<R: ProcessRunner> Copy for GiteaAt<'_, R> {}
495
496/// Generate [`GiteaAt`] forwarders: `bare` methods forward verbatim, `dir`
497/// methods inject `self.dir` as the first argument.
498macro_rules! gitea_at_forwarders {
499    (
500        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
501        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
502    ) => {
503        impl<'a, R: ProcessRunner> GiteaAt<'a, R> {
504            $(
505                #[doc = concat!("Bound form of [`Gitea`]'s `", stringify!($bn), "`.")]
506                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
507                    self.tea.$bn($($ba),*).await
508                }
509            )*
510            $(
511                #[doc = concat!("Bound form of [`Gitea`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
512                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
513                    self.tea.$dn(self.dir, $($da),*).await
514                }
515            )*
516        }
517    };
518}
519
520gitea_at_forwarders! {
521    bare {
522        fn run(args: &[String]) -> Result<String>;
523        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
524        fn run_args(args: &[&str]) -> Result<String>;
525        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
526        fn version() -> Result<String>;
527        fn auth_status() -> Result<bool>;
528    }
529    dir {
530        fn pr_list() -> Result<Vec<PullRequest>>;
531        fn pr_view(number: u64) -> Result<PullRequest>;
532        fn pr_create(spec: PrCreate) -> Result<String>;
533        fn pr_merge(number: u64, strategy: MergeStrategy) -> Result<()>;
534        fn pr_close(number: u64) -> Result<()>;
535        fn issue_list() -> Result<Vec<Issue>>;
536        fn issue_view(number: u64) -> Result<Issue>;
537        fn issue_create(title: &str, body: &str) -> Result<String>;
538        fn release_list() -> Result<Vec<Release>>;
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use processkit::{RecordingRunner, Reply, ScriptedRunner};
546
547    #[test]
548    fn binary_name_is_tea() {
549        assert_eq!(BINARY, "tea");
550    }
551
552    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
553    #[allow(dead_code)]
554    fn bound_view_is_copy_for_default_runner() {
555        fn assert_copy<T: Copy>() {}
556        assert_copy::<GiteaAt<'static, processkit::JobRunner>>();
557    }
558
559    // The bound view (`tea.at(dir)`) must produce byte-identical argv to the
560    // dir-taking call.
561    #[tokio::test]
562    async fn bound_view_matches_dir_taking_calls() {
563        let dir = Path::new("/repo");
564        let rec = RecordingRunner::replying(Reply::ok("[]"));
565        let tea = Gitea::with_runner(&rec);
566
567        tea.pr_list(dir).await.unwrap();
568        tea.at(dir).pr_list().await.unwrap();
569        tea.pr_close(dir, 7).await.unwrap();
570        tea.at(dir).pr_close(7).await.unwrap();
571
572        let calls = rec.calls();
573        assert_eq!(calls[0].args_str(), calls[1].args_str());
574        assert_eq!(calls[2].args_str(), calls[3].args_str());
575        assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
576    }
577
578    #[tokio::test]
579    async fn run_args_forwards_str_slices() {
580        let tea = Gitea::with_runner(ScriptedRunner::new().on(["whoami"], Reply::ok("me\n")));
581        assert_eq!(tea.run_args(&["whoami"]).await.unwrap(), "me");
582    }
583
584    // Hermetic: real pr_list() arg-building + JSON deserialization against canned
585    // output — no `tea` binary or network needed, so this runs on CI. The fixture
586    // is tea's *table* shape: all-string values, flat `head`/`base`, `url` column.
587    #[tokio::test]
588    async fn pr_list_parses_scripted_json() {
589        let json = r#"[{"index":"7","title":"Add X","state":"open","head":"feat/x","base":"main","url":"u"}]"#;
590        let tea = Gitea::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
591        let prs = tea.pr_list(Path::new(".")).await.expect("pr_list");
592        assert_eq!(prs.len(), 1);
593        assert_eq!(prs[0].number, 7);
594        assert_eq!(prs[0].head_branch, "feat/x");
595    }
596
597    // pr_view lists all states and filters by number; tea folds merge into the
598    // `state` column (`"merged"`), from which the `merged` flag is derived.
599    #[tokio::test]
600    async fn pr_view_filters_listing_by_number() {
601        let json = r#"[
602            {"index":"7","title":"Seven","state":"open","head":"a","base":"main","url":"u"},
603            {"index":"9","title":"Nine","state":"merged","head":"b","base":"main","url":"u"}
604        ]"#;
605        let tea = Gitea::with_runner(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
606        let pr = tea.pr_view(Path::new("."), 9).await.expect("pr_view");
607        assert_eq!(pr.title, "Nine");
608        assert!(pr.merged);
609    }
610
611    // pr_view passes `--state all` + `--fields` so a closed/merged PR is found
612    // with its branches/url, and a missing number is a parse error, not a panic.
613    #[tokio::test]
614    async fn pr_view_requests_all_states_and_errors_when_missing() {
615        let rec = RecordingRunner::replying(Reply::ok("[]"));
616        let tea = Gitea::with_runner(&rec);
617        let err = tea.pr_view(Path::new("/repo"), 5).await.unwrap_err();
618        assert!(matches!(err, Error::Parse { .. }));
619        assert_eq!(
620            rec.only_call().args_str(),
621            [
622                "pr",
623                "list",
624                "--state",
625                "all",
626                "--limit",
627                "999",
628                "--fields",
629                "index,title,state,head,base,url",
630                "--output",
631                "json"
632            ]
633        );
634    }
635
636    // pr_list pins an explicit `--limit 100` (so tea's default page size of 30
637    // does not silently truncate) and `--fields` (so head/base/url are present).
638    #[tokio::test]
639    async fn pr_list_pins_limit_and_fields() {
640        let rec = RecordingRunner::replying(Reply::ok("[]"));
641        let tea = Gitea::with_runner(&rec);
642        tea.pr_list(Path::new("/repo")).await.expect("pr_list");
643        assert_eq!(
644            rec.only_call().args_str(),
645            [
646                "pr",
647                "list",
648                "--limit",
649                "100",
650                "--fields",
651                "index,title,state,head,base,url",
652                "--output",
653                "json"
654            ]
655        );
656    }
657
658    // auth_status reads the logins array: non-empty ⇒ true, empty ⇒ false.
659    #[tokio::test]
660    async fn auth_status_counts_logins() {
661        let yes = Gitea::with_runner(
662            ScriptedRunner::new().on(["login", "list"], Reply::ok(r#"[{"name":"gitea"}]"#)),
663        );
664        assert!(yes.auth_status().await.unwrap());
665        let no = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::ok("[]")));
666        assert!(!no.auth_status().await.unwrap());
667        // Some tea builds print nothing (not `[]`) when no login is configured;
668        // that must read as `false`, not a parse error.
669        let empty = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::ok("")));
670        assert!(!empty.auth_status().await.unwrap());
671        // A non-zero exit (e.g. tea erroring because no config file exists) must
672        // read as "not logged in" — never an error.
673        let failed = Gitea::with_runner(
674            ScriptedRunner::new().on(["login", "list"], Reply::fail(1, "no config")),
675        );
676        assert!(!failed.auth_status().await.unwrap());
677        let weird =
678            Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::fail(2, "boom")));
679        assert!(!weird.auth_status().await.unwrap());
680    }
681
682    // A timed-out login check must error, not silently report "not logged in".
683    #[tokio::test]
684    async fn auth_status_errors_on_timeout() {
685        let tea = Gitea::with_runner(ScriptedRunner::new().on(["login", "list"], Reply::timeout()));
686        assert!(matches!(
687            tea.auth_status().await.unwrap_err(),
688            Error::Timeout { .. }
689        ));
690    }
691
692    // pr_create assembles title/description then optional head/base.
693    #[tokio::test]
694    async fn pr_create_appends_head_and_base() {
695        let rec = RecordingRunner::replying(Reply::ok("#9\n"));
696        let tea = Gitea::with_runner(&rec);
697        tea.pr_create(
698            Path::new("/repo"),
699            PrCreate::new("T", "B").head("feat/x").base("main"),
700        )
701        .await
702        .expect("pr_create");
703        assert_eq!(
704            rec.only_call().args_str(),
705            [
706                "pr",
707                "create",
708                "--title",
709                "T",
710                "--description",
711                "B",
712                "--head",
713                "feat/x",
714                "--base",
715                "main"
716            ]
717        );
718    }
719
720    // pr_merge maps the strategy to `--style`; pr_close to `pr close <n>`.
721    #[tokio::test]
722    async fn pr_merge_and_close_build_expected_argv() {
723        let rec = RecordingRunner::replying(Reply::ok(""));
724        let tea = Gitea::with_runner(&rec);
725        tea.pr_merge(Path::new("/repo"), 5, MergeStrategy::Squash)
726            .await
727            .expect("merge");
728        assert_eq!(
729            rec.only_call().args_str(),
730            ["pr", "merge", "5", "--style", "squash"]
731        );
732
733        let rec = RecordingRunner::replying(Reply::ok(""));
734        let tea = Gitea::with_runner(&rec);
735        tea.pr_close(Path::new("/repo"), 5).await.expect("close");
736        assert_eq!(rec.only_call().args_str(), ["pr", "close", "5"]);
737    }
738
739    // issue_list parses tea's table shape (all-string `index` column) and pins
740    // `--limit 100 --fields … --output json`.
741    #[tokio::test]
742    async fn issue_list_parses_scripted_json() {
743        let json = r#"[{"index":"12","title":"Bug","state":"open","body":"broken","url":"u"}]"#;
744        let tea = Gitea::with_runner(ScriptedRunner::new().on(["issues", "list"], Reply::ok(json)));
745        let issues = tea.issue_list(Path::new(".")).await.expect("issue_list");
746        assert_eq!(issues.len(), 1);
747        assert_eq!(issues[0].number, 12);
748        assert_eq!(issues[0].title, "Bug");
749    }
750
751    #[tokio::test]
752    async fn issue_list_pins_limit_and_fields() {
753        let rec = RecordingRunner::replying(Reply::ok("[]"));
754        let tea = Gitea::with_runner(&rec);
755        tea.issue_list(Path::new("/repo"))
756            .await
757            .expect("issue_list");
758        assert_eq!(
759            rec.only_call().args_str(),
760            [
761                "issues",
762                "list",
763                "--limit",
764                "100",
765                "--fields",
766                "index,title,state,body,url",
767                "--output",
768                "json"
769            ]
770        );
771    }
772
773    // issue_view is a first-class `tea issues <index> --output json` returning a
774    // single **typed** object (numeric `index`) — not a list+filter like pr_view.
775    #[tokio::test]
776    async fn issue_view_uses_bare_index_and_parses_object() {
777        let rec = RecordingRunner::replying(Reply::ok(
778            r#"{"index":7,"title":"One","state":"closed","body":"b","url":"u"}"#,
779        ));
780        let tea = Gitea::with_runner(&rec);
781        let issue = tea
782            .issue_view(Path::new("/repo"), 7)
783            .await
784            .expect("issue_view");
785        assert_eq!(issue.number, 7);
786        assert_eq!(issue.state, "closed");
787        assert_eq!(
788            rec.only_call().args_str(),
789            ["issues", "7", "--output", "json"]
790        );
791    }
792
793    // issue_create assembles title/description; returns the trimmed stdout.
794    #[tokio::test]
795    async fn issue_create_builds_argv_and_returns_output() {
796        let rec = RecordingRunner::replying(Reply::ok("#12 Bug\nhttps://gitea/issues/12\n"));
797        let tea = Gitea::with_runner(&rec);
798        let out = tea
799            .issue_create(Path::new("/repo"), "Bug", "broken")
800            .await
801            .expect("issue_create");
802        assert_eq!(out, "#12 Bug\nhttps://gitea/issues/12");
803        assert_eq!(
804            rec.only_call().args_str(),
805            [
806                "issues",
807                "create",
808                "--title",
809                "Bug",
810                "--description",
811                "broken"
812            ]
813        );
814    }
815
816    // release_list parses tea's fixed release table (all-string values, tea's
817    // `toSnakeCase`d `tag-_name`/`published _at`/`status` keys) and pins the argv.
818    // tea exposes no release-page URL, so `url` is empty.
819    #[tokio::test]
820    async fn release_list_parses_scripted_json() {
821        let json = r#"[{"tag-_name":"0.1","title":"First","status":"released","published _at":"2023-07-26T13:02:36Z","tar/_zip url":"https://gitea/0.1.tar.gz\nhttps://gitea/0.1.zip"}]"#;
822        let tea =
823            Gitea::with_runner(ScriptedRunner::new().on(["releases", "list"], Reply::ok(json)));
824        let releases = tea
825            .release_list(Path::new("."))
826            .await
827            .expect("release_list");
828        assert_eq!(releases.len(), 1);
829        assert_eq!(releases[0].tag, "0.1");
830        assert_eq!(releases[0].title, "First");
831        assert_eq!(releases[0].url, "");
832        assert!(!releases[0].draft);
833    }
834
835    #[tokio::test]
836    async fn release_list_pins_limit_100() {
837        let rec = RecordingRunner::replying(Reply::ok("[]"));
838        let tea = Gitea::with_runner(&rec);
839        tea.release_list(Path::new("/repo"))
840            .await
841            .expect("release_list");
842        assert_eq!(
843            rec.only_call().args_str(),
844            ["releases", "list", "--limit", "100", "--output", "json"]
845        );
846    }
847
848    #[cfg(feature = "mock")]
849    #[tokio::test]
850    async fn consumer_mocks_the_interface() {
851        let mut mock = MockGiteaApi::new();
852        mock.expect_auth_status().returning(|| Ok(true));
853        assert!(mock.auth_status().await.unwrap());
854    }
855}
856
857// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
858#[doc = include_str!("../docs/gitea.md")]
859#[allow(rustdoc::broken_intra_doc_links)]
860pub mod guide {}