Skip to main content

soar_dl/
platform.rs

1use std::{env, sync::LazyLock};
2
3use percent_encoding::percent_decode_str;
4use regex::Regex;
5use ureq::http::header::AUTHORIZATION;
6use url::Url;
7
8use crate::{error::DownloadError, http_client::SHARED_AGENT};
9
10#[derive(Debug)]
11pub enum PlatformUrl {
12    Github {
13        project: String,
14        tag: Option<String>,
15    },
16    Gitlab {
17        project: String,
18        tag: Option<String>,
19    },
20    Oci {
21        reference: String,
22    },
23    Direct {
24        url: String,
25    },
26}
27
28static GITHUB_RE: LazyLock<Regex> = LazyLock::new(|| {
29    Regex::new(r"^(?i)(?:https?://)?(?:github(?:\.com)?[:/])([^/@]+/[^/@]+)(?:@([^\r\n]+))?$")
30        .expect("unable to compile github release regex")
31});
32
33static GITLAB_RE: LazyLock<Regex> = LazyLock::new(|| {
34    Regex::new(
35        r"^(?i)(?:https?://)?(?:gitlab(?:\.com)?[:/])((?:\d+)|(?:[^/@]+(?:/[^/@]+)*))(?:@([^\r\n]+))?$",
36    )
37    .expect("unable to compile gitlab release regex")
38});
39
40impl PlatformUrl {
41    /// Classifies an input string as a platform URL and returns the corresponding `PlatformUrl` variant.
42    ///
43    /// This inspects the input URL (or reference) and returns:
44    /// - `Oci` when the normalized string starts with `ghcr.io/` (treated as an OCI reference).
45    /// - `Github` when it matches the GitHub repository pattern, extracting project and optional tag.
46    /// - `Gitlab` when it matches the GitLab repository pattern, extracting project and optional tag
47    ///   (except when the project looks like an API path or contains `/-/`, which is treated as `Direct`).
48    /// - `Direct` when the input parses as a valid URL with a scheme and host.
49    ///
50    /// Returns `None` if the input cannot be classified or parsed as a valid URL.
51    ///
52    /// # Examples
53    ///
54    /// ```
55    /// use soar_dl::platform::PlatformUrl;
56    ///
57    /// // OCI reference
58    /// let _ = PlatformUrl::parse("ghcr.io/myorg/myimage:latest").unwrap();
59    ///
60    /// // GitHub repo
61    /// let _ = PlatformUrl::parse("https://github.com/owner/repo/releases/tag/v1.0").unwrap();
62    ///
63    /// // Direct URL
64    /// let _ = PlatformUrl::parse("https://example.com/resource").unwrap();
65    /// ```
66    pub fn parse(url: impl AsRef<str>) -> Option<Self> {
67        let url = url.as_ref();
68
69        let normalized = url
70            .trim_start_matches("https://")
71            .trim_start_matches("http://");
72        if normalized.starts_with("ghcr.io/") {
73            return Some(Self::Oci {
74                reference: normalized.to_string(),
75            });
76        }
77
78        if let Some((project, tag)) = Self::parse_repo(&GITHUB_RE, url) {
79            return Some(Self::Github {
80                project,
81                tag,
82            });
83        }
84
85        if let Some((project, tag)) = Self::parse_repo(&GITLAB_RE, url) {
86            if project.starts_with("api/") || project.contains("/-/") {
87                return Url::parse(url).ok().map(|_| {
88                    Self::Direct {
89                        url: url.to_string(),
90                    }
91                });
92            }
93            return Some(Self::Gitlab {
94                project,
95                tag,
96            });
97        }
98
99        Url::parse(url)
100            .ok()
101            .filter(|u| !u.scheme().is_empty() && u.host().is_some())
102            .map(|_| {
103                Self::Direct {
104                    url: url.to_string(),
105                }
106            })
107    }
108
109    /// Extracts a repository project path and an optional tag from `url` using `re`.
110    ///
111    /// The returned `project` is the first capture group as a `String`. The optional `tag`
112    /// is taken from the second capture group (if present), with surrounding quotes and
113    /// spaces removed and URI-decoded. Returns `None` if the regex does not match.
114    fn parse_repo(re: &Regex, url: &str) -> Option<(String, Option<String>)> {
115        let caps = re.captures(url)?;
116        let project = caps.get(1)?.as_str().to_string();
117        let tag = caps
118            .get(2)
119            .map(|m| m.as_str().trim_matches(&['\'', '"', ' '][..]))
120            .filter(|s| !s.is_empty())
121            .and_then(|s| {
122                percent_decode_str(s)
123                    .decode_utf8()
124                    .ok()
125                    .map(|cow| cow.into_owned())
126            });
127
128        Some((project, tag))
129    }
130}
131
132/// Fetches JSON from an API base URL with an optional Bearer token and returns the deserialized
133/// items as a `Vec<T>`.
134///
135/// If the environment variable named by `token_env[0]` (or `token_env[1]` as fallback) is set,
136/// it is included as an `Authorization: Bearer <token>` header. The response body must be either
137/// a JSON array (mapped to `Vec<T>`) or a single JSON object (mapped to a one-element `Vec<T>`);
138/// other shapes produce `DownloadError::InvalidResponse`. Non-success HTTP statuses produce
139/// `DownloadError::HttpError`.
140pub fn fetch_releases_json<T>(
141    path: &str,
142    base: &str,
143    token_env: [&str; 2],
144) -> Result<Vec<T>, DownloadError>
145where
146    T: serde::de::DeserializeOwned,
147{
148    let url = format!("{}{}", base, path);
149    let mut req = SHARED_AGENT.get(&url);
150
151    if let Ok(token) = env::var(token_env[0]).or_else(|_| env::var(token_env[1])) {
152        req = req.header(AUTHORIZATION, &format!("Bearer {}", token.trim()));
153    }
154
155    let mut resp = req.call()?;
156    let status = resp.status();
157
158    if !status.is_success() {
159        return Err(DownloadError::HttpError {
160            status: status.as_u16(),
161            url: url.clone(),
162        });
163    }
164
165    let json: serde_json::Value = resp
166        .body_mut()
167        .read_json()
168        .map_err(|_| DownloadError::InvalidResponse)?;
169
170    match json {
171        serde_json::Value::Array(_) => {
172            serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse)
173        }
174        serde_json::Value::Object(_) => {
175            let single: T =
176                serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse)?;
177            Ok(vec![single])
178        }
179        _ => Err(DownloadError::InvalidResponse),
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_platform_url_parse_oci() {
189        let result = PlatformUrl::parse("ghcr.io/owner/repo:latest");
190        match result {
191            Some(PlatformUrl::Oci {
192                reference,
193            }) => {
194                assert_eq!(reference, "ghcr.io/owner/repo:latest");
195            }
196            _ => panic!("Expected OCI variant"),
197        }
198    }
199
200    #[test]
201    fn test_platform_url_parse_oci_with_prefix() {
202        let result = PlatformUrl::parse("https://ghcr.io/owner/repo:v1.0");
203        match result {
204            Some(PlatformUrl::Oci {
205                reference,
206            }) => {
207                assert_eq!(reference, "ghcr.io/owner/repo:v1.0");
208            }
209            _ => panic!("Expected OCI variant"),
210        }
211    }
212
213    #[test]
214    fn test_platform_url_parse_github_https() {
215        let result = PlatformUrl::parse("https://github.com/owner/repo");
216        match result {
217            Some(PlatformUrl::Github {
218                project,
219                tag,
220            }) => {
221                assert_eq!(project, "owner/repo");
222                assert_eq!(tag, None);
223            }
224            _ => panic!("Expected Github variant"),
225        }
226    }
227
228    #[test]
229    fn test_platform_url_parse_github_with_tag() {
230        let result = PlatformUrl::parse("https://github.com/owner/repo@v1.0.0");
231        match result {
232            Some(PlatformUrl::Github {
233                project,
234                tag,
235            }) => {
236                assert_eq!(project, "owner/repo");
237                assert_eq!(tag, Some("v1.0.0".to_string()));
238            }
239            _ => panic!("Expected Github variant with tag"),
240        }
241    }
242
243    #[test]
244    fn test_platform_url_parse_github_shorthand() {
245        let result = PlatformUrl::parse("github:owner/repo");
246        match result {
247            Some(PlatformUrl::Github {
248                project,
249                tag,
250            }) => {
251                assert_eq!(project, "owner/repo");
252                assert_eq!(tag, None);
253            }
254            _ => panic!("Expected Github variant"),
255        }
256    }
257
258    #[test]
259    fn test_platform_url_parse_github_case_insensitive() {
260        let result = PlatformUrl::parse("GITHUB.COM/owner/repo");
261        match result {
262            Some(PlatformUrl::Github {
263                project,
264                tag,
265            }) => {
266                assert_eq!(project, "owner/repo");
267                assert_eq!(tag, None);
268            }
269            _ => panic!("Expected Github variant"),
270        }
271    }
272
273    #[test]
274    fn test_platform_url_parse_gitlab_https() {
275        let result = PlatformUrl::parse("https://gitlab.com/owner/repo");
276        match result {
277            Some(PlatformUrl::Gitlab {
278                project,
279                tag,
280            }) => {
281                assert_eq!(project, "owner/repo");
282                assert_eq!(tag, None);
283            }
284            _ => panic!("Expected Gitlab variant"),
285        }
286    }
287
288    #[test]
289    fn test_platform_url_parse_gitlab_with_tag() {
290        let result = PlatformUrl::parse("https://gitlab.com/owner/repo@v2.0");
291        match result {
292            Some(PlatformUrl::Gitlab {
293                project,
294                tag,
295            }) => {
296                assert_eq!(project, "owner/repo");
297                assert_eq!(tag, Some("v2.0".to_string()));
298            }
299            _ => panic!("Expected Gitlab variant with tag"),
300        }
301    }
302
303    #[test]
304    fn test_platform_url_parse_gitlab_numeric_project() {
305        let result = PlatformUrl::parse("https://gitlab.com/12345@v1.0");
306        match result {
307            Some(PlatformUrl::Gitlab {
308                project,
309                tag,
310            }) => {
311                assert_eq!(project, "12345");
312                assert_eq!(tag, Some("v1.0".to_string()));
313            }
314            _ => panic!("Expected Gitlab variant with numeric project"),
315        }
316    }
317
318    #[test]
319    fn test_platform_url_parse_gitlab_nested_groups() {
320        let result = PlatformUrl::parse("https://gitlab.com/group/subgroup/repo");
321        match result {
322            Some(PlatformUrl::Gitlab {
323                project,
324                tag,
325            }) => {
326                assert_eq!(project, "group/subgroup/repo");
327                assert_eq!(tag, None);
328            }
329            _ => panic!("Expected Gitlab variant with nested groups"),
330        }
331    }
332
333    #[test]
334    fn test_platform_url_parse_gitlab_api_path_as_direct() {
335        let result = PlatformUrl::parse("https://gitlab.com/api/v4/projects/123");
336        match result {
337            Some(PlatformUrl::Direct {
338                url,
339            }) => {
340                assert_eq!(url, "https://gitlab.com/api/v4/projects/123");
341            }
342            _ => panic!("Expected Direct variant for API path"),
343        }
344    }
345
346    #[test]
347    fn test_platform_url_parse_gitlab_special_path_as_direct() {
348        let result = PlatformUrl::parse("https://gitlab.com/owner/repo/-/releases");
349        match result {
350            Some(PlatformUrl::Direct {
351                url,
352            }) => {
353                assert_eq!(url, "https://gitlab.com/owner/repo/-/releases");
354            }
355            _ => panic!("Expected Direct variant for special path"),
356        }
357    }
358
359    #[test]
360    fn test_platform_url_parse_direct_url() {
361        let result = PlatformUrl::parse("https://example.com/download/file.tar.gz");
362        match result {
363            Some(PlatformUrl::Direct {
364                url,
365            }) => {
366                assert_eq!(url, "https://example.com/download/file.tar.gz");
367            }
368            _ => panic!("Expected Direct variant"),
369        }
370    }
371
372    #[test]
373    fn test_platform_url_parse_direct_http() {
374        let result = PlatformUrl::parse("http://example.com/file.zip");
375        match result {
376            Some(PlatformUrl::Direct {
377                url,
378            }) => {
379                assert_eq!(url, "http://example.com/file.zip");
380            }
381            _ => panic!("Expected Direct variant"),
382        }
383    }
384
385    #[test]
386    fn test_platform_url_parse_invalid() {
387        assert!(PlatformUrl::parse("not a valid url").is_none());
388        assert!(PlatformUrl::parse("").is_none());
389        assert!(PlatformUrl::parse("/not/a/url").is_none());
390    }
391
392    #[test]
393    fn test_platform_url_parse_github_with_spaces_in_tag() {
394        let result = PlatformUrl::parse("github.com/owner/repo@v1.0 beta");
395        match result {
396            Some(PlatformUrl::Github {
397                project,
398                tag,
399            }) => {
400                assert_eq!(project, "owner/repo");
401                assert_eq!(tag, Some("v1.0 beta".to_string()));
402            }
403            _ => panic!("Expected Github variant with tag containing spaces"),
404        }
405    }
406
407    #[test]
408    fn test_platform_url_parse_tag_with_special_chars() {
409        let result = PlatformUrl::parse("github.com/owner/repo@v1.0-rc.1+build.123");
410        match result {
411            Some(PlatformUrl::Github {
412                project,
413                tag,
414            }) => {
415                assert_eq!(project, "owner/repo");
416                assert_eq!(tag, Some("v1.0-rc.1+build.123".to_string()));
417            }
418            _ => panic!("Expected Github variant with complex tag"),
419        }
420    }
421
422    #[test]
423    fn test_parse_repo_with_quotes() {
424        let result = PlatformUrl::parse("github.com/owner/repo@'v1.0'");
425        match result {
426            Some(PlatformUrl::Github {
427                project,
428                tag,
429            }) => {
430                assert_eq!(project, "owner/repo");
431                assert_eq!(tag, Some("v1.0".to_string()));
432            }
433            _ => panic!("Expected quotes to be stripped from tag"),
434        }
435    }
436
437    #[test]
438    fn test_parse_repo_percent_encoded_tag() {
439        let result = PlatformUrl::parse("github.com/owner/repo@v1.0%2Bbuild");
440        match result {
441            Some(PlatformUrl::Github {
442                project,
443                tag,
444            }) => {
445                assert_eq!(project, "owner/repo");
446                assert_eq!(tag, Some("v1.0+build".to_string()));
447            }
448            _ => panic!("Expected percent-encoded tag to be decoded"),
449        }
450    }
451}