Skip to main content

rootle_github/
types.rs

1//! Serde models for the GitHub REST API (only what rootle consumes).
2//! UI-facing types (`SearchItem`, `TreeNode`, …) live in
3//! `crate::provider` — the trait boundary; these are wire models.
4
5use serde::Deserialize;
6
7#[derive(Debug, Deserialize)]
8pub struct SearchReposResponse {
9    pub items: Vec<RepoItem>,
10}
11
12#[derive(Debug, Deserialize)]
13pub struct RepoItem {
14    pub full_name: String,
15}
16
17#[derive(Debug, Deserialize)]
18pub struct SearchUsersResponse {
19    pub items: Vec<UserItem>,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct UserItem {
24    pub login: String,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct OrgRepoItem {
29    pub name: String,
30    #[serde(default)]
31    pub description: Option<String>,
32    #[serde(default)]
33    pub private: bool,
34    #[serde(default)]
35    pub archived: bool,
36    #[serde(default)]
37    pub pushed_at: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct RepoMeta {
42    pub default_branch: String,
43}
44
45/// GET /search/code (Accept: application/vnd.github.text-match+json).
46#[derive(Debug, Deserialize)]
47pub struct SearchCodeResponse {
48    #[serde(default)]
49    pub total_count: u64,
50    pub items: Vec<CodeItem>,
51}
52
53#[derive(Debug, Deserialize)]
54pub struct CodeItem {
55    pub path: String,
56    pub sha: String,
57    pub repository: CodeRepo,
58    #[serde(default)]
59    pub text_matches: Vec<TextMatch>,
60}
61
62#[derive(Debug, Deserialize)]
63pub struct CodeRepo {
64    pub full_name: String,
65    #[serde(default)]
66    pub default_branch: Option<String>,
67}
68
69/// A matched fragment: snippet text + match positions (byte indices
70/// into `fragment`). Fragments carry no absolute line numbers — the
71/// app locates them in the fetched blob for real line numbers.
72#[derive(Debug, Deserialize)]
73pub struct TextMatch {
74    pub fragment: String,
75    #[serde(default)]
76    pub matches: Vec<MatchRange>,
77}
78
79#[derive(Debug, Deserialize)]
80pub struct MatchRange {
81    pub text: String,
82}
83
84/// GET /repos/{o}/{r}/commits/{sha} (v1.6, plans/0028): the commit
85/// viewer's detail. `status` stays raw — GitHub also emits
86/// "changed"/"unchanged", which the provider seam degrades to
87/// `modified`.
88#[derive(Debug, Deserialize)]
89pub struct CommitResponse {
90    pub sha: String,
91    pub commit: CommitMeta,
92    #[serde(default)]
93    pub parents: Vec<CommitParent>,
94    #[serde(default)]
95    pub files: Vec<CommitFileItem>,
96    #[serde(default)]
97    pub html_url: Option<String>,
98    #[serde(skip)]
99    pub truncated: bool,
100}
101
102#[derive(Debug, Deserialize)]
103pub struct CommitMeta {
104    pub message: String,
105    pub author: Option<CommitAuthor>,
106}
107
108#[derive(Debug, Deserialize)]
109pub struct CommitAuthor {
110    pub name: Option<String>,
111    pub date: Option<String>,
112}
113
114#[derive(Debug, Deserialize)]
115pub struct CommitParent {
116    pub sha: String,
117}
118
119#[derive(Debug, Deserialize)]
120pub struct CommitFileItem {
121    pub filename: String,
122    #[serde(default)]
123    pub status: Option<String>,
124    /// Absent when GitHub truncates very large diffs.
125    #[serde(default)]
126    pub additions: Option<u32>,
127    #[serde(default)]
128    pub deletions: Option<u32>,
129    pub patch: Option<String>,
130    #[serde(default)]
131    pub previous_filename: Option<String>,
132}
133
134/// GET /repos/{o}/{r}/git/trees/{branch}?recursive=1
135#[derive(Debug, Clone, Deserialize, serde::Serialize)]
136pub struct TreeResponse {
137    pub sha: String,
138    #[serde(default)]
139    pub truncated: bool,
140    pub tree: Vec<TreeEntry>,
141}
142
143#[derive(Debug, Clone, Deserialize, serde::Serialize)]
144pub struct TreeEntry {
145    pub path: String,
146    #[serde(rename = "type")]
147    pub kind: String, // "blob" | "tree"
148    pub sha: String,
149    #[serde(default)]
150    pub size: Option<u64>,
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn parses_repo_search_payload() {
159        let json = r#"{"total_count": 1, "items": [{"full_name": "ratatui/ratatui"}]}"#;
160        let parsed: SearchReposResponse = serde_json::from_str(json).unwrap();
161        assert_eq!(parsed.items[0].full_name, "ratatui/ratatui");
162    }
163
164    #[test]
165    fn parses_org_search_payload() {
166        let json = r#"{"items": [{"login": "tokio-rs", "type": "Organization"}]}"#;
167        let parsed: SearchUsersResponse = serde_json::from_str(json).unwrap();
168        assert_eq!(parsed.items[0].login, "tokio-rs");
169    }
170
171    #[test]
172    fn parses_tree_payload() {
173        let json = r#"{
174            "sha": "abc123",
175            "truncated": false,
176            "tree": [
177                {"path": "src", "mode": "040000", "type": "tree", "sha": "d1"},
178                {"path": "src/lib.rs", "mode": "100644", "type": "blob", "sha": "f1", "size": 42}
179            ]
180        }"#;
181        let parsed: TreeResponse = serde_json::from_str(json).unwrap();
182        assert_eq!(parsed.tree.len(), 2);
183        assert_eq!(parsed.tree[0].kind, "tree");
184        assert_eq!(parsed.tree[1].size, Some(42));
185    }
186
187    #[test]
188    fn parses_commit_payload() {
189        let json = r#"{
190            "sha": "6dcb09b5",
191            "commit": {
192                "message": "Fix the race\n\nBody line.",
193                "author": {"name": "octocat", "date": "2026-09-06T10:00:00Z"}
194            },
195            "parents": [{"sha": "aaa111"}, {"sha": "bbb222"}],
196            "files": [
197                {"filename": "src/lib.rs", "status": "modified", "additions": 2,
198                 "deletions": 1, "patch": "@@ -1 +1,2 @@\n-old\n+new\n+line"},
199                {"filename": "src/old.rs", "status": "removed", "additions": 0, "deletions": 9},
200                {"filename": "src/moved.rs", "status": "renamed", "previous_filename": "src/orig.rs",
201                 "additions": 1, "deletions": 1},
202                {"filename": "img/logo.png", "status": "changed", "additions": 3, "deletions": 0}
203            ]
204        }"#;
205        let parsed: CommitResponse = serde_json::from_str(json).unwrap();
206        assert_eq!(parsed.commit.message, "Fix the race\n\nBody line.");
207        assert_eq!(
208            (
209                parsed.commit.author.as_ref().unwrap().name.as_deref(),
210                parsed.commit.author.as_ref().unwrap().date.as_deref()
211            ),
212            (Some("octocat"), Some("2026-09-06T10:00:00Z"))
213        );
214        assert_eq!(parsed.parents.len(), 2);
215        assert_eq!(parsed.parents[1].sha, "bbb222");
216        assert_eq!(
217            parsed.files[0].patch.as_deref(),
218            Some("@@ -1 +1,2 @@\n-old\n+new\n+line")
219        );
220        assert_eq!(parsed.files[1].deletions, Some(9));
221        assert_eq!(
222            parsed.files[2].previous_filename.as_deref(),
223            Some("src/orig.rs")
224        );
225        // Binary/no-patch files: patch absent, status stays raw wire
226        // ("changed" is not one of the seam's four).
227        assert_eq!(parsed.files[3].patch, None);
228        assert_eq!(parsed.files[3].status.as_deref(), Some("changed"));
229    }
230}