Skip to main content

vcs_github/
parse.rs

1//! Typed results from `gh … --json` and the deserialization helpers. Parsing is
2//! pure, so these tests are hermetic and run on CI.
3
4use processkit::{Error, Result};
5use serde::Deserialize;
6use serde::de::DeserializeOwned;
7
8use crate::BINARY;
9
10/// A pull request (`gh pr list/view --json number,title,state,headRefName,baseRefName,url`).
11#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
12#[non_exhaustive]
13pub struct PullRequest {
14    /// PR number.
15    pub number: u64,
16    /// PR title.
17    pub title: String,
18    /// State, e.g. `"OPEN"`, `"MERGED"`, `"CLOSED"`.
19    pub state: String,
20    /// Source (head) branch name.
21    #[serde(rename = "headRefName", default)]
22    pub head_ref_name: String,
23    /// Target (base) branch name.
24    #[serde(rename = "baseRefName", default)]
25    pub base_ref_name: String,
26    /// Web URL.
27    #[serde(default)]
28    pub url: String,
29}
30
31/// An issue (`gh issue list --json number,title,state`).
32#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
33#[non_exhaustive]
34pub struct Issue {
35    /// Issue number.
36    pub number: u64,
37    /// Issue title.
38    pub title: String,
39    /// State, e.g. `"OPEN"`, `"CLOSED"`.
40    pub state: String,
41}
42
43/// A repository (`gh repo view --json name,owner,description,url,isPrivate,defaultBranchRef`).
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct Repo {
47    /// Repository name.
48    pub name: String,
49    /// Owner login.
50    pub owner: String,
51    /// Description, `None` when GitHub returns `null`.
52    pub description: Option<String>,
53    /// Web URL.
54    pub url: String,
55    /// `true` for a private repository.
56    pub is_private: bool,
57    /// Default branch name (empty for an empty repository).
58    pub default_branch: String,
59}
60
61// gh nests `owner` and `defaultBranchRef` as objects; deserialize into this and
62// flatten into the public `Repo`.
63#[derive(Deserialize)]
64struct RepoJson {
65    name: String,
66    owner: OwnerJson,
67    #[serde(default)]
68    description: Option<String>,
69    url: String,
70    #[serde(rename = "isPrivate")]
71    is_private: bool,
72    #[serde(rename = "defaultBranchRef", default)]
73    default_branch_ref: Option<BranchRefJson>,
74}
75
76#[derive(Deserialize)]
77struct OwnerJson {
78    login: String,
79}
80
81#[derive(Deserialize)]
82struct BranchRefJson {
83    name: String,
84}
85
86/// Deserialize `gh --json` output into `T`, mapping parse errors to
87/// [`Error::Parse`].
88pub(crate) fn from_json<T: DeserializeOwned>(json: &str) -> Result<T> {
89    serde_json::from_str(json).map_err(|e| Error::Parse {
90        program: BINARY.to_string(),
91        message: e.to_string(),
92    })
93}
94
95/// Parse `gh repo view --json …` output, flattening the nested objects.
96pub(crate) fn parse_repo(json: &str) -> Result<Repo> {
97    let raw: RepoJson = from_json(json)?;
98    Ok(Repo {
99        name: raw.name,
100        owner: raw.owner.login,
101        description: raw.description,
102        url: raw.url,
103        is_private: raw.is_private,
104        default_branch: raw.default_branch_ref.map(|b| b.name).unwrap_or_default(),
105    })
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn parses_pr_list() {
114        let json = r#"[
115            {"number": 12, "title": "Add feature", "state": "OPEN",
116             "headRefName": "feat/x", "baseRefName": "main", "url": "https://gh/pr/12"}
117        ]"#;
118        let prs: Vec<PullRequest> = from_json(json).expect("parse prs");
119        assert_eq!(prs.len(), 1);
120        assert_eq!(
121            prs[0],
122            PullRequest {
123                number: 12,
124                title: "Add feature".into(),
125                state: "OPEN".into(),
126                head_ref_name: "feat/x".into(),
127                base_ref_name: "main".into(),
128                url: "https://gh/pr/12".into(),
129            }
130        );
131    }
132
133    #[test]
134    fn parses_issue_list() {
135        let json = r#"[{"number": 3, "title": "Docs", "state": "OPEN"}]"#;
136        let issues: Vec<Issue> = from_json(json).expect("parse issues");
137        assert_eq!(issues[0].number, 3);
138    }
139
140    #[test]
141    fn parses_repo_flattening_nested_objects() {
142        let json = r#"{
143            "name": "vcs-toolkit-rs",
144            "owner": {"login": "ZelAnton"},
145            "description": null,
146            "url": "https://gh/repo",
147            "isPrivate": false,
148            "defaultBranchRef": {"name": "main"}
149        }"#;
150        let repo = parse_repo(json).expect("parse repo");
151        assert_eq!(repo.name, "vcs-toolkit-rs");
152        assert_eq!(repo.owner, "ZelAnton");
153        assert_eq!(repo.description, None);
154        assert_eq!(repo.default_branch, "main");
155        assert!(!repo.is_private);
156    }
157
158    #[test]
159    fn empty_repo_has_blank_default_branch() {
160        let json = r#"{"name":"e","owner":{"login":"o"},"url":"u","isPrivate":true,"defaultBranchRef":null}"#;
161        let repo = parse_repo(json).expect("parse repo");
162        assert_eq!(repo.default_branch, "");
163        assert!(repo.is_private);
164    }
165
166    #[test]
167    fn malformed_json_is_a_parse_error() {
168        match from_json::<Vec<Issue>>("not json").unwrap_err() {
169            Error::Parse { .. } => {}
170            other => panic!("expected Parse, got {other:?}"),
171        }
172    }
173}