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_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}