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}