upstream_rs/providers/github/
github_adapter.rs1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use std::path::Path;
4
5use crate::models::common::Version;
6use crate::models::provider::{Asset, Release, RepositorySearchFilters, RepositorySearchResult};
7use crate::providers::release_provider::ReleaseProvider;
8
9use super::github_client::GithubClient;
10use super::github_dtos::{GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto};
11
12#[derive(Debug, Clone)]
13pub struct GithubAdapter {
14 client: GithubClient,
15}
16
17impl GithubAdapter {
18 pub fn new(client: GithubClient) -> Self {
19 Self { client }
20 }
21
22 pub async fn download_asset<F>(
23 &self,
24 asset: &Asset,
25 destination_path: &Path,
26 dl_callback: &mut Option<F>,
27 ) -> Result<()>
28 where
29 F: FnMut(u64, u64),
30 {
31 self.client
32 .download_file(&asset.download_url, destination_path, dl_callback)
33 .await
34 }
35
36 pub async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
37 let dto = self.client.get_release_by_tag(slug, tag).await?;
38 Ok(self.convert_release(dto))
39 }
40
41 pub async fn get_latest_release(&self, slug: &str) -> Result<Release> {
42 let dto = self.client.get_latest_release(slug).await?;
43 Ok(self.convert_release(dto))
44 }
45
46 pub async fn get_releases(
47 &self,
48 slug: &str,
49 per_page: Option<u32>,
50 max_total: Option<u32>,
51 ) -> Result<Vec<Release>> {
52 let dtos = self.client.get_releases(slug, per_page, max_total).await?;
53 Ok(dtos
54 .into_iter()
55 .map(|dto| self.convert_release(dto))
56 .collect())
57 }
58
59 pub async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
60 self.client.get_branch_head_sha(slug, branch).await
61 }
62
63 pub async fn search_repositories(
64 &self,
65 query: &str,
66 limit: Option<u32>,
67 filters: &RepositorySearchFilters,
68 ) -> Result<Vec<RepositorySearchResult>> {
69 let dto = self
70 .client
71 .search_repositories(query, limit, filters)
72 .await?;
73 Ok(dto
74 .items
75 .into_iter()
76 .map(Self::convert_search_result)
77 .collect())
78 }
79
80 fn convert_asset(dto: GithubAssetDto) -> Asset {
81 let created_at = Self::parse_timestamp(&dto.created_at);
82 Asset::new(
83 dto.browser_download_url,
84 dto.id as u64,
85 dto.name,
86 dto.size as u64,
87 created_at,
88 )
89 }
90
91 fn convert_release(&self, dto: GithubReleaseDto) -> Release {
92 let assets: Vec<Asset> = dto.assets.into_iter().map(Self::convert_asset).collect();
93 let version =
94 Version::from_tag(&dto.tag_name).unwrap_or_else(|_| Version::new(0, 0, 0, false));
95 Release {
96 id: dto.id as u64,
97 tag: dto.tag_name,
98 name: dto.name,
99 body: dto.body,
100 is_draft: dto.draft,
101 is_prerelease: dto.prerelease,
102 published_at: Self::parse_timestamp(&dto.published_at),
103 assets,
104 version,
105 }
106 }
107
108 fn convert_search_result(dto: GithubRepositorySearchItemDto) -> RepositorySearchResult {
109 RepositorySearchResult {
110 repo_slug: dto.full_name,
111 display_name: dto.name,
112 description: dto.description,
113 stars: dto.stargazers_count,
114 language: dto.language,
115 updated_at: Self::parse_timestamp(&dto.updated_at),
116 }
117 }
118
119 fn parse_timestamp(raw: &str) -> DateTime<Utc> {
120 if raw.trim().is_empty() {
121 return DateTime::<Utc>::MIN_UTC;
122 }
123 raw.parse::<DateTime<Utc>>()
124 .unwrap_or(DateTime::<Utc>::MIN_UTC)
125 }
126}
127
128#[async_trait::async_trait(?Send)]
129impl ReleaseProvider for GithubAdapter {
130 async fn get_latest_release(&self, slug: &str) -> Result<Release> {
131 GithubAdapter::get_latest_release(self, slug).await
132 }
133
134 async fn get_releases(
135 &self,
136 slug: &str,
137 per_page: Option<u32>,
138 max_total: Option<u32>,
139 ) -> Result<Vec<Release>> {
140 GithubAdapter::get_releases(self, slug, per_page, max_total).await
141 }
142
143 async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
144 GithubAdapter::get_release_by_tag(self, slug, tag).await
145 }
146
147 async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
148 GithubAdapter::get_branch_head_sha(self, slug, branch).await
149 }
150
151 async fn search_repositories(
152 &self,
153 query: &str,
154 limit: Option<u32>,
155 filters: &RepositorySearchFilters,
156 ) -> Result<Vec<RepositorySearchResult>> {
157 GithubAdapter::search_repositories(self, query, limit, filters).await
158 }
159
160 async fn download_asset(
161 &self,
162 asset: &Asset,
163 destination_path: &Path,
164 dl_callback: Option<&mut (dyn FnMut(u64, u64) + '_)>,
165 ) -> Result<()> {
166 let mut forwarded = dl_callback;
167 GithubAdapter::download_asset(self, asset, destination_path, &mut forwarded).await
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::GithubAdapter;
174 use crate::providers::github::github_client::GithubClient;
175 use crate::providers::github::github_dtos::{
176 GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto,
177 };
178
179 #[test]
180 fn parse_timestamp_returns_min_for_invalid_or_empty_values() {
181 assert_eq!(
182 GithubAdapter::parse_timestamp(""),
183 chrono::DateTime::<chrono::Utc>::MIN_UTC
184 );
185 assert_eq!(
186 GithubAdapter::parse_timestamp("not-a-date"),
187 chrono::DateTime::<chrono::Utc>::MIN_UTC
188 );
189 }
190
191 #[test]
192 fn convert_release_maps_assets_and_version() {
193 let adapter = GithubAdapter::new(GithubClient::new(None).expect("github client"));
194 let dto = GithubReleaseDto {
195 id: 12,
196 tag_name: "v2.3.4".to_string(),
197 name: "Release 2.3.4".to_string(),
198 body: "notes".to_string(),
199 prerelease: true,
200 draft: false,
201 published_at: "2026-02-21T00:00:00Z".to_string(),
202 assets: vec![GithubAssetDto {
203 id: 9,
204 name: "tool-linux-x86_64.tar.gz".to_string(),
205 browser_download_url: "https://example.invalid/tool-linux-x86_64.tar.gz"
206 .to_string(),
207 size: 123,
208 content_type: "application/gzip".to_string(),
209 created_at: "2026-02-20T00:00:00Z".to_string(),
210 }],
211 };
212
213 let release = adapter.convert_release(dto);
214 assert_eq!(release.id, 12);
215 assert_eq!(release.version.to_string(), "2.3.4");
216 assert!(release.is_prerelease);
217 assert_eq!(release.assets.len(), 1);
218 assert_eq!(release.assets[0].id, 9);
219 }
220
221 #[test]
222 fn convert_search_result_maps_fields() {
223 let dto = GithubRepositorySearchItemDto {
224 full_name: "BurntSushi/ripgrep".to_string(),
225 name: "ripgrep".to_string(),
226 description: "fast grep".to_string(),
227 stargazers_count: 123,
228 language: "Rust".to_string(),
229 updated_at: "2026-05-09T00:00:00Z".to_string(),
230 archived: false,
231 fork: false,
232 };
233
234 let result = GithubAdapter::convert_search_result(dto);
235 assert_eq!(result.repo_slug, "BurntSushi/ripgrep");
236 assert_eq!(result.display_name, "ripgrep");
237 assert_eq!(result.description, "fast grep");
238 assert_eq!(result.stars, 123);
239 assert_eq!(result.language, "Rust");
240 }
241
242 #[test]
243 fn convert_search_result_invalid_timestamp_uses_min() {
244 let dto = GithubRepositorySearchItemDto {
245 full_name: "owner/repo".to_string(),
246 name: "repo".to_string(),
247 description: String::new(),
248 stargazers_count: 0,
249 language: String::new(),
250 updated_at: "nope".to_string(),
251 archived: false,
252 fork: false,
253 };
254
255 let result = GithubAdapter::convert_search_result(dto);
256 assert_eq!(result.updated_at, chrono::DateTime::<chrono::Utc>::MIN_UTC);
257 }
258}