Skip to main content

upstream_rs/providers/github/
github_adapter.rs

1use 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_releases_newer_than(
60        &self,
61        slug: &str,
62        from_version: &Version,
63        per_page: Option<u32>,
64    ) -> Result<Vec<Release>> {
65        let per_page = per_page.unwrap_or(30);
66        let mut page = 1;
67        let mut releases = Vec::new();
68
69        loop {
70            let batch = self.client.get_releases_page(slug, per_page, page).await?;
71            if batch.is_empty() {
72                break;
73            }
74
75            let partial_page = batch.len() < per_page as usize;
76            let mut reached_from_version = false;
77            for dto in batch {
78                let parsed_version = Version::from_tag(&dto.tag_name).ok();
79                let release = self.convert_release(dto);
80                if parsed_version
81                    .as_ref()
82                    .is_some_and(|version| version <= from_version)
83                {
84                    reached_from_version = true;
85                    continue;
86                }
87                releases.push(release);
88            }
89
90            if reached_from_version || partial_page {
91                break;
92            }
93
94            page += 1;
95        }
96
97        Ok(releases)
98    }
99
100    pub async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
101        self.client.get_branch_head_sha(slug, branch).await
102    }
103
104    pub async fn search_repositories(
105        &self,
106        query: &str,
107        limit: Option<u32>,
108        filters: &RepositorySearchFilters,
109    ) -> Result<Vec<RepositorySearchResult>> {
110        let dto = self
111            .client
112            .search_repositories(query, limit, filters)
113            .await?;
114        Ok(dto
115            .items
116            .into_iter()
117            .map(Self::convert_search_result)
118            .collect())
119    }
120
121    fn convert_asset(dto: GithubAssetDto) -> Asset {
122        let created_at = Self::parse_timestamp(&dto.created_at);
123        Asset::new(
124            dto.browser_download_url,
125            dto.id as u64,
126            dto.name,
127            dto.size as u64,
128            created_at,
129        )
130    }
131
132    fn convert_release(&self, dto: GithubReleaseDto) -> Release {
133        let assets: Vec<Asset> = dto.assets.into_iter().map(Self::convert_asset).collect();
134        let version =
135            Version::from_tag(&dto.tag_name).unwrap_or_else(|_| Version::new(0, 0, 0, false));
136        Release {
137            id: dto.id as u64,
138            tag: dto.tag_name,
139            name: dto.name,
140            body: dto.body,
141            is_draft: dto.draft,
142            is_prerelease: dto.prerelease,
143            published_at: Self::parse_timestamp(&dto.published_at),
144            assets,
145            version,
146        }
147    }
148
149    fn convert_search_result(dto: GithubRepositorySearchItemDto) -> RepositorySearchResult {
150        RepositorySearchResult {
151            repo_slug: dto.full_name,
152            display_name: dto.name,
153            description: dto.description,
154            stars: dto.stargazers_count,
155            language: dto.language,
156            updated_at: Self::parse_timestamp(&dto.updated_at),
157        }
158    }
159
160    fn parse_timestamp(raw: &str) -> DateTime<Utc> {
161        if raw.trim().is_empty() {
162            return DateTime::<Utc>::MIN_UTC;
163        }
164        raw.parse::<DateTime<Utc>>()
165            .unwrap_or(DateTime::<Utc>::MIN_UTC)
166    }
167}
168
169#[async_trait::async_trait(?Send)]
170impl ReleaseProvider for GithubAdapter {
171    async fn get_latest_release(&self, slug: &str) -> Result<Release> {
172        GithubAdapter::get_latest_release(self, slug).await
173    }
174
175    async fn get_releases(
176        &self,
177        slug: &str,
178        per_page: Option<u32>,
179        max_total: Option<u32>,
180    ) -> Result<Vec<Release>> {
181        GithubAdapter::get_releases(self, slug, per_page, max_total).await
182    }
183
184    async fn get_releases_newer_than(
185        &self,
186        slug: &str,
187        from_version: &Version,
188        per_page: Option<u32>,
189    ) -> Result<Vec<Release>> {
190        GithubAdapter::get_releases_newer_than(self, slug, from_version, per_page).await
191    }
192
193    async fn get_release_by_tag(&self, slug: &str, tag: &str) -> Result<Release> {
194        GithubAdapter::get_release_by_tag(self, slug, tag).await
195    }
196
197    async fn get_branch_head_sha(&self, slug: &str, branch: &str) -> Result<String> {
198        GithubAdapter::get_branch_head_sha(self, slug, branch).await
199    }
200
201    async fn search_repositories(
202        &self,
203        query: &str,
204        limit: Option<u32>,
205        filters: &RepositorySearchFilters,
206    ) -> Result<Vec<RepositorySearchResult>> {
207        GithubAdapter::search_repositories(self, query, limit, filters).await
208    }
209
210    async fn download_asset(
211        &self,
212        asset: &Asset,
213        destination_path: &Path,
214        dl_callback: Option<&mut (dyn FnMut(u64, u64) + '_)>,
215    ) -> Result<()> {
216        let mut forwarded = dl_callback;
217        GithubAdapter::download_asset(self, asset, destination_path, &mut forwarded).await
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::GithubAdapter;
224    use crate::providers::github::github_client::GithubClient;
225    use crate::providers::github::github_dtos::{
226        GithubAssetDto, GithubReleaseDto, GithubRepositorySearchItemDto,
227    };
228
229    #[test]
230    fn parse_timestamp_returns_min_for_invalid_or_empty_values() {
231        assert_eq!(
232            GithubAdapter::parse_timestamp(""),
233            chrono::DateTime::<chrono::Utc>::MIN_UTC
234        );
235        assert_eq!(
236            GithubAdapter::parse_timestamp("not-a-date"),
237            chrono::DateTime::<chrono::Utc>::MIN_UTC
238        );
239    }
240
241    #[test]
242    fn convert_release_maps_assets_and_version() {
243        let adapter =
244            GithubAdapter::new(GithubClient::new(None, Default::default()).expect("github client"));
245        let dto = GithubReleaseDto {
246            id: 12,
247            tag_name: "v2.3.4".to_string(),
248            name: "Release 2.3.4".to_string(),
249            body: "notes".to_string(),
250            prerelease: true,
251            draft: false,
252            published_at: "2026-02-21T00:00:00Z".to_string(),
253            assets: vec![GithubAssetDto {
254                id: 9,
255                name: "tool-linux-x86_64.tar.gz".to_string(),
256                browser_download_url: "https://example.invalid/tool-linux-x86_64.tar.gz"
257                    .to_string(),
258                size: 123,
259                content_type: "application/gzip".to_string(),
260                created_at: "2026-02-20T00:00:00Z".to_string(),
261            }],
262        };
263
264        let release = adapter.convert_release(dto);
265        assert_eq!(release.id, 12);
266        assert_eq!(release.version.to_string(), "2.3.4");
267        assert!(release.is_prerelease);
268        assert_eq!(release.assets.len(), 1);
269        assert_eq!(release.assets[0].id, 9);
270    }
271
272    #[test]
273    fn convert_search_result_maps_fields() {
274        let dto = GithubRepositorySearchItemDto {
275            full_name: "BurntSushi/ripgrep".to_string(),
276            name: "ripgrep".to_string(),
277            description: "fast grep".to_string(),
278            stargazers_count: 123,
279            language: "Rust".to_string(),
280            updated_at: "2026-05-09T00:00:00Z".to_string(),
281            archived: false,
282            fork: false,
283        };
284
285        let result = GithubAdapter::convert_search_result(dto);
286        assert_eq!(result.repo_slug, "BurntSushi/ripgrep");
287        assert_eq!(result.display_name, "ripgrep");
288        assert_eq!(result.description, "fast grep");
289        assert_eq!(result.stars, 123);
290        assert_eq!(result.language, "Rust");
291    }
292
293    #[test]
294    fn convert_search_result_invalid_timestamp_uses_min() {
295        let dto = GithubRepositorySearchItemDto {
296            full_name: "owner/repo".to_string(),
297            name: "repo".to_string(),
298            description: String::new(),
299            stargazers_count: 0,
300            language: String::new(),
301            updated_at: "nope".to_string(),
302            archived: false,
303            fork: false,
304        };
305
306        let result = GithubAdapter::convert_search_result(dto);
307        assert_eq!(result.updated_at, chrono::DateTime::<chrono::Utc>::MIN_UTC);
308    }
309}