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