Skip to main content

vcs_gitea/
parse.rs

1//! Typed results from `tea … --output json` and the deserialization helpers.
2//!
3//! **`tea --output json` is NOT the Gitea REST shape.** It has two distinct
4//! paths (verified against tea's source — `modules/print/table.go` for the table,
5//! `cmd/issues.go` for the issue-detail `buildIssueData`):
6//!
7//! - **List** commands (`pr/issues/releases list`) serialize tea's print-table:
8//!   a JSON **array of string-maps** whose keys are column headers run through
9//!   tea's `toSnakeCase`, and whose **values are all JSON strings** — never typed
10//!   numbers/bools, never `html_url`, never nested `head.ref`/`base.ref`. We
11//!   select the columns we need with `--fields` where the command supports it.
12//!   `toSnakeCase` is quirky: its `(.)([A-Z][a-z]+)` rule inserts a stray `_`
13//!   before each capitalised run, so the fixed `releases` headers (`Tag-Name`,
14//!   `Published At`, `Tar/Zip URL`) become the literal keys `"tag-_name"`,
15//!   `"published _at"`, `"tar/_zip url"` (spaces/slashes preserved). Lowercase
16//!   single-word `--fields` headers (`index`, `head`, …) snake-case to themselves.
17//! - **Detail** views (`issues <n>`) bypass the table and marshal a hand-written
18//!   **typed** struct (real numbers, mixed-case keys), a single object.
19//!
20//! So the internal list DTOs are string-typed (`From` parses `index` → `u64`),
21//! the issue-detail DTO is typed, and the public structs are the flattened
22//! result either way. Parsing is pure, so the unit tests are hermetic — but the
23//! fixtures must encode tea's *table* shape, not the REST shape; the definitive
24//! check is the `#[ignore]` real-`tea` tests in `tests/cli.rs`.
25
26use processkit::{Error, Result};
27use serde::Deserialize;
28use serde::de::DeserializeOwned;
29
30use crate::BINARY;
31
32/// A pull request (`tea pr list --output json`), flattened from tea's table
33/// columns (`index`/`title`/`state`/`head`/`base`/`url`).
34#[derive(Debug, Clone, PartialEq, Eq)]
35#[non_exhaustive]
36pub struct PullRequest {
37    /// PR number (tea's `index` column).
38    pub number: u64,
39    /// PR title.
40    pub title: String,
41    /// State, e.g. `"open"`, `"closed"`, `"merged"` — tea folds the merge flag
42    /// into this column (a merged PR reads `"merged"`, not `"closed"`).
43    pub state: String,
44    /// Whether the PR has been merged — derived from `state == "merged"` (tea has
45    /// no separate merged column).
46    pub merged: bool,
47    /// Source (head) branch name (tea's `head` column, a flat branch name).
48    pub head_branch: String,
49    /// Target (base) branch name (tea's `base` column, a flat branch name).
50    pub base_branch: String,
51    /// Web URL (tea's `url` column).
52    pub url: String,
53}
54
55// A row of `tea pr list --output json` — every value is a JSON string. `index`
56// has no `default`: a row always carries it, so a missing id is a real parse
57// failure, not a silent `0` that `pr_view` could then "find".
58#[derive(Deserialize)]
59struct PrJson {
60    index: String,
61    #[serde(default)]
62    title: String,
63    #[serde(default)]
64    state: String,
65    #[serde(default)]
66    head: String,
67    #[serde(default)]
68    base: String,
69    #[serde(default)]
70    url: String,
71}
72
73impl TryFrom<PrJson> for PullRequest {
74    type Error = Error;
75
76    fn try_from(raw: PrJson) -> Result<Self> {
77        Ok(PullRequest {
78            number: parse_index(&raw.index)?,
79            title: raw.title,
80            // tea's `state` column already folds in the merge flag.
81            merged: raw.state.eq_ignore_ascii_case("merged"),
82            state: raw.state,
83            head_branch: raw.head,
84            base_branch: raw.base,
85            url: raw.url,
86        })
87    }
88}
89
90/// An issue (`tea issues list --output json` / `tea issues <index> --output
91/// json`). The two tea paths differ — the **list** is a string-table row, the
92/// **detail** view a typed object — but both flatten into this struct.
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct Issue {
96    /// Issue number (tea's `index`).
97    pub number: u64,
98    /// Issue title.
99    pub title: String,
100    /// State, e.g. `"open"`, `"closed"`.
101    pub state: String,
102    /// Issue body / description.
103    pub body: String,
104    /// Web URL (tea's `url`).
105    pub url: String,
106}
107
108// A row of `tea issues list --output json` — all-string values, `index` column.
109// We pass `--fields index,title,state,body,url`, so all are present, but keep
110// `default` on the optionals to tolerate a future column trim.
111#[derive(Deserialize)]
112struct IssueListJson {
113    index: String,
114    #[serde(default)]
115    title: String,
116    #[serde(default)]
117    state: String,
118    #[serde(default)]
119    body: String,
120    #[serde(default)]
121    url: String,
122}
123
124impl TryFrom<IssueListJson> for Issue {
125    type Error = Error;
126
127    fn try_from(raw: IssueListJson) -> Result<Self> {
128        Ok(Issue {
129            number: parse_index(&raw.index)?,
130            title: raw.title,
131            state: raw.state,
132            body: raw.body,
133            url: raw.url,
134        })
135    }
136}
137
138// The single-issue **detail** view (`tea issues <n> --output json`) is a typed
139// object built by tea's `buildIssueData` (`cmd/issues.go`): `index` is a
140// real number, keys are `index`/`title`/`state`/`body`/`url`. No `default` on
141// `index`: a missing id is a real parse failure.
142#[derive(Deserialize)]
143struct IssueDetailJson {
144    index: u64,
145    #[serde(default)]
146    title: String,
147    #[serde(default)]
148    state: String,
149    #[serde(default)]
150    body: String,
151    #[serde(default)]
152    url: String,
153}
154
155impl From<IssueDetailJson> for Issue {
156    fn from(raw: IssueDetailJson) -> Self {
157        Issue {
158            number: raw.index,
159            title: raw.title,
160            state: raw.state,
161            body: raw.body,
162            url: raw.url,
163        }
164    }
165}
166
167/// A release (`tea releases list --output json`), flattened from tea's fixed
168/// release-table columns. **`tea releases` exposes no web-page URL** (only a
169/// combined tar/zip download URL, which we deliberately don't surface), so
170/// [`url`](Release::url) is always empty for Gitea — see the field doc.
171#[derive(Debug, Clone, PartialEq, Eq)]
172#[non_exhaustive]
173pub struct Release {
174    /// Git tag the release points at (tea's `Tag-Name` column).
175    pub tag: String,
176    /// Release title (tea's `Title` column).
177    pub title: String,
178    /// Publish timestamp, e.g. `"2023-07-26T13:02:36Z"` (tea's `Published At`
179    /// column); empty for an unpublished draft.
180    pub published_at: String,
181    /// Whether the release is a draft (derived from tea's `Status` column).
182    pub draft: bool,
183    /// Whether the release is a pre-release (derived from tea's `Status` column).
184    pub prerelease: bool,
185    /// **Always empty for Gitea.** `tea releases list` has no release-page URL
186    /// column (only a tar/zip download URL, intentionally not surfaced here).
187    pub url: String,
188}
189
190// A row of `tea releases list --output json`: all-string values, fixed columns.
191// `releases list` has no `--fields` flag. The keys are tea's Title-Case headers
192// (`Tag-Name`/`Published At`/`Status`/`Tar/Zip URL`) run through tea's
193// `toSnakeCase`, whose `(.)([A-Z][a-z]+)` rule inserts a stray `_` before each
194// capitalised run — so the literal keys are `tag-_name`, `published _at`,
195// `status`, `tar/_zip url` (verified against tea's `modules/print/table.go`).
196#[derive(Deserialize)]
197struct ReleaseJson {
198    // No `default`: a row always carries the tag column, so a missing tag is a
199    // real parse failure rather than a silent empty string.
200    #[serde(rename = "tag-_name")]
201    tag_name: String,
202    #[serde(default)]
203    title: String,
204    #[serde(rename = "published _at", default)]
205    published_at: String,
206    // tea collapses draft/prerelease/released into one `Status` column.
207    #[serde(default)]
208    status: String,
209}
210
211impl From<ReleaseJson> for Release {
212    fn from(raw: ReleaseJson) -> Self {
213        Release {
214            tag: raw.tag_name,
215            title: raw.title,
216            published_at: raw.published_at,
217            draft: raw.status.eq_ignore_ascii_case("draft"),
218            prerelease: raw.status.eq_ignore_ascii_case("prerelease"),
219            // tea's release table carries no web-page URL column.
220            url: String::new(),
221        }
222    }
223}
224
225/// Parse a tea table cell holding an issue/PR index (always a JSON **string**,
226/// e.g. `"4"`) into a `u64`, mapping a non-numeric value to [`Error::Parse`].
227fn parse_index(value: &str) -> Result<u64> {
228    value.trim().parse().map_err(|_| Error::Parse {
229        program: BINARY.to_string(),
230        message: format!("expected a numeric index, got {value:?}"),
231    })
232}
233
234/// Deserialize `tea … --output json` output into `T`, mapping parse errors to
235/// [`Error::Parse`].
236pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
237    serde_json::from_str(json).map_err(|e| Error::Parse {
238        program: BINARY.to_string(),
239        message: e.to_string(),
240    })
241}
242
243/// Parse `tea pr list --output json` into the flattened [`PullRequest`]s.
244pub(crate) fn parse_pr_list(json: &str) -> Result<Vec<PullRequest>> {
245    let raw: Vec<PrJson> = from_json(json)?;
246    raw.into_iter().map(PullRequest::try_from).collect()
247}
248
249/// Parse `tea issues list --output json` into the flattened [`Issue`]s.
250pub(crate) fn parse_issue_list(json: &str) -> Result<Vec<Issue>> {
251    let raw: Vec<IssueListJson> = from_json(json)?;
252    raw.into_iter().map(Issue::try_from).collect()
253}
254
255/// Parse `tea issues <index> --output json` into a single [`Issue`]. Unlike the
256/// list, the single-issue view yields one **typed** object, not an array.
257pub(crate) fn parse_issue(json: &str) -> Result<Issue> {
258    let raw: IssueDetailJson = from_json(json)?;
259    Ok(Issue::from(raw))
260}
261
262/// Parse `tea releases list --output json` into the flattened [`Release`]s.
263pub(crate) fn parse_release_list(json: &str) -> Result<Vec<Release>> {
264    let raw: Vec<ReleaseJson> = from_json(json)?;
265    Ok(raw.into_iter().map(Release::from).collect())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // `tea pr list --output json` is a table: all-string values, `index` column,
273    // flat `head`/`base`, `url` column. (We pass `--fields index,title,state,
274    // head,base,url`.)
275    #[test]
276    fn parses_pr_list_table_row() {
277        let json = r#"[
278            {"index": "7", "title": "Add X", "state": "open",
279             "head": "feat/x", "base": "main", "url": "https://gitea/pr/7"}
280        ]"#;
281        let prs = parse_pr_list(json).expect("parse prs");
282        assert_eq!(prs.len(), 1);
283        assert_eq!(
284            prs[0],
285            PullRequest {
286                number: 7,
287                title: "Add X".into(),
288                state: "open".into(),
289                merged: false,
290                head_branch: "feat/x".into(),
291                base_branch: "main".into(),
292                url: "https://gitea/pr/7".into(),
293            }
294        );
295    }
296
297    // tea folds the merge flag into the `state` column: a merged PR reads
298    // `state="merged"`, from which `merged` is derived.
299    #[test]
300    fn pr_state_merged_derives_the_flag() {
301        let json = r#"[{"index": "9", "title": "done", "state": "merged",
302                        "head": "f", "base": "main", "url": "u"}]"#;
303        let prs = parse_pr_list(json).expect("parse prs");
304        assert_eq!(prs[0].number, 9);
305        assert!(prs[0].merged);
306        assert_eq!(prs[0].state, "merged");
307    }
308
309    // A non-numeric `index` string is a real parse failure, not a silent `0`
310    // that `pr_view` could then "find".
311    #[test]
312    fn pr_non_numeric_index_is_a_parse_error() {
313        match parse_pr_list(r#"[{"index": "x", "title": "t", "state": "open"}]"#).unwrap_err() {
314            Error::Parse { .. } => {}
315            other => panic!("expected Parse, got {other:?}"),
316        }
317    }
318
319    #[test]
320    fn malformed_json_is_a_parse_error() {
321        match parse_pr_list("not json").unwrap_err() {
322            Error::Parse { .. } => {}
323            other => panic!("expected Parse, got {other:?}"),
324        }
325    }
326
327    // `tea issues list --output json` is a table — all-string values, `index`
328    // column. We request `--fields index,title,state,body,url`.
329    #[test]
330    fn parses_issue_list_table_row() {
331        let json = r#"[
332            {"index": "12", "title": "Bug", "state": "open", "body": "broken",
333             "url": "https://gitea/issues/12"}
334        ]"#;
335        let issues = parse_issue_list(json).expect("parse issues");
336        assert_eq!(issues.len(), 1);
337        assert_eq!(
338            issues[0],
339            Issue {
340                number: 12,
341                title: "Bug".into(),
342                state: "open".into(),
343                body: "broken".into(),
344                url: "https://gitea/issues/12".into(),
345            }
346        );
347    }
348
349    // A column trim (body/url absent) must still parse via the field defaults.
350    #[test]
351    fn issue_list_tolerates_trimmed_columns() {
352        let json = r#"[{"index": "4", "title": "wip", "state": "open"}]"#;
353        let issues = parse_issue_list(json).expect("parse issues");
354        assert_eq!(issues[0].number, 4);
355        assert_eq!(issues[0].body, "");
356        assert_eq!(issues[0].url, "");
357    }
358
359    // The single-issue **detail** view (`tea issues <index> --output json`) is a
360    // typed object: `index` is a real JSON number, not a string.
361    #[test]
362    fn parses_single_issue_detail_object() {
363        let json = r#"{"index": 7, "title": "One", "state": "closed", "body": "b",
364                       "url": "https://gitea/issues/7"}"#;
365        let issue = parse_issue(json).expect("parse issue");
366        assert_eq!(issue.number, 7);
367        assert_eq!(issue.title, "One");
368        assert_eq!(issue.state, "closed");
369        assert_eq!(issue.url, "https://gitea/issues/7");
370    }
371
372    // `tea releases list --output json` is a fixed table: all-string values,
373    // tea's `toSnakeCase`d header keys (`tag-_name`, `published _at`, `status`,
374    // `tar/_zip url` — note the stray `_` tea's snake-caser inserts), and NO
375    // release-page URL column.
376    #[test]
377    fn parses_release_list_table_row() {
378        let json = r#"[
379            {"tag-_name": "0.1", "title": "First", "status": "released",
380             "published _at": "2023-07-26T13:02:36Z",
381             "tar/_zip url": "https://gitea/0.1.tar.gz\nhttps://gitea/0.1.zip"}
382        ]"#;
383        let releases = parse_release_list(json).expect("parse releases");
384        assert_eq!(releases.len(), 1);
385        assert_eq!(
386            releases[0],
387            Release {
388                tag: "0.1".into(),
389                title: "First".into(),
390                published_at: "2023-07-26T13:02:36Z".into(),
391                draft: false,
392                prerelease: false,
393                url: String::new(), // tea exposes no release-page URL
394            }
395        );
396    }
397
398    // A draft release: tea's `status` column is "draft", and `published _at` is
399    // empty (zero time). The status string drives the `draft` flag.
400    #[test]
401    fn release_status_drives_draft_flag() {
402        let json = r#"[{"tag-_name": "v2", "title": "Two", "status": "draft",
403                        "published _at": ""}]"#;
404        let releases = parse_release_list(json).expect("parse releases");
405        assert_eq!(releases[0].tag, "v2");
406        assert!(releases[0].draft);
407        assert_eq!(releases[0].published_at, "");
408        assert!(!releases[0].prerelease);
409    }
410
411    // A prerelease: `status` = "prerelease" sets the prerelease flag only.
412    #[test]
413    fn release_status_drives_prerelease_flag() {
414        let json = r#"[{"tag-_name": "v3-rc1", "title": "RC", "status": "prerelease",
415                        "published _at": "2026-01-02T03:04:05Z"}]"#;
416        let releases = parse_release_list(json).expect("parse releases");
417        assert!(releases[0].prerelease);
418        assert!(!releases[0].draft);
419    }
420
421    // A release row without the tag column is a real parse failure, not a silent
422    // empty tag.
423    #[test]
424    fn release_missing_tag_is_a_parse_error() {
425        match parse_release_list(r#"[{"title": "no tag"}]"#).unwrap_err() {
426            Error::Parse { .. } => {}
427            other => panic!("expected Parse, got {other:?}"),
428        }
429    }
430
431    // auth_status counts the logins array; an empty array means "not logged in".
432    #[test]
433    fn login_array_counts() {
434        let some: Vec<serde_json::Value> =
435            from_json(r#"[{"name":"gitea"}]"#).expect("parse logins");
436        assert!(!some.is_empty());
437        let none: Vec<serde_json::Value> = from_json("[]").expect("parse empty");
438        assert!(none.is_empty());
439    }
440}