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 {}