github_proxy/
resource.rs

1use crate::proxy::Proxy;
2use strum_macros::EnumIter;
3
4/// Github resource types
5#[cfg_attr(feature = "wasm", wasm_bindgen::prelude::wasm_bindgen)]
6#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
7#[derive(EnumIter, Debug, PartialEq, Hash, Eq, Clone)]
8pub enum Resource {
9    /// Raw file in a repository
10    /// Format: owner/repo/reference/path
11    /// reference can be: branch name, tag, commit hash, or refs/heads/branch
12    File {
13        owner: String,
14        repo: String,
15        reference: String,
16        path: String,
17    },
18    /// Release asset
19    /// Format: owner/repo/tag/filename
20    Release {
21        owner: String,
22        repo: String,
23        tag: String,
24        name: String,
25    },
26}
27
28impl Resource {
29    /// Create a new file resource
30    ///
31    /// # Arguments
32    /// * `owner` - Repository owner
33    /// * `repo` - Repository name
34    /// * `reference` - Git reference (branch, tag, commit hash, or refs/heads/branch)
35    /// * `path` - File path in the repository
36    pub fn file(owner: String, repo: String, reference: String, path: String) -> Self {
37        Resource::File {
38            owner,
39            repo,
40            reference,
41            path,
42        }
43    }
44
45    /// Create a new release resource
46    pub fn release(owner: String, repo: String, tag: String, name: String) -> Self {
47        Resource::Release {
48            owner,
49            repo,
50            tag,
51            name,
52        }
53    }
54
55    /// Convert the resource to a proxied URL
56    ///
57    /// Returns None if the proxy type doesn't support the resource type
58    /// (e.g., jsdelivr doesn't support release assets from /releases/download/)
59    pub fn url(&self, proxy_type: &Proxy) -> Option<String> {
60        match self {
61            Resource::File {
62                owner,
63                repo,
64                reference,
65                path,
66            } => Some(match proxy_type {
67                Proxy::Github => {
68                    format!(
69                        "https://github.com/{}/{}/raw/{}/{}",
70                        owner, repo, reference, path
71                    )
72                }
73                Proxy::Xget => {
74                    format!(
75                        "https://xget.xi-xu.me/gh/{}/{}/raw/{}/{}",
76                        owner, repo, reference, path
77                    )
78                }
79                Proxy::GhProxy => {
80                    format!(
81                        "https://gh-proxy.com/https://github.com/{}/{}/raw/{}/{}",
82                        owner, repo, reference, path
83                    )
84                }
85                Proxy::Jsdelivr => {
86                    format!(
87                        "https://cdn.jsdelivr.net/gh/{}/{}@{}/{}",
88                        owner, repo, reference, path
89                    )
90                }
91                Proxy::Statically => {
92                    format!(
93                        "https://cdn.statically.io/gh/{}/{}/{}/{}",
94                        owner, repo, reference, path
95                    )
96                }
97            }),
98            Resource::Release {
99                owner,
100                repo,
101                tag,
102                name,
103            } => match proxy_type {
104                Proxy::Github => Some(format!(
105                    "https://github.com/{}/{}/releases/download/{}/{}",
106                    owner, repo, tag, name
107                )),
108                Proxy::Xget => Some(format!(
109                    "https://xget.xi-xu.me/gh/{}/{}/releases/download/{}/{}",
110                    owner, repo, tag, name
111                )),
112                Proxy::GhProxy => Some(format!(
113                    "https://gh-proxy.com/https://github.com/{}/{}/releases/download/{}/{}",
114                    owner, repo, tag, name
115                )),
116                // jsdelivr doesn't support release assets from /releases/download/
117                Proxy::Jsdelivr => None,
118                // statically doesn't support release assets from /releases/download/
119                Proxy::Statically => None,
120            },
121        }
122    }
123}
124
125use crate::error::ConversionError;
126use regex::Regex;
127use std::sync::OnceLock;
128
129// Lazy static regex patterns
130fn raw_file_regex() -> &'static Regex {
131    static RE: OnceLock<Regex> = OnceLock::new();
132    RE.get_or_init(|| {
133        // Match everything after /raw/ and then split to find the path
134        Regex::new(r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/raw/(?P<rest>.+)$")
135            .unwrap()
136    })
137}
138
139fn blob_file_regex() -> &'static Regex {
140    static RE: OnceLock<Regex> = OnceLock::new();
141    RE.get_or_init(|| {
142        // Match everything after /blob/ and then split to find the path
143        Regex::new(r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/blob/(?P<rest>.+)$")
144            .unwrap()
145    })
146}
147
148fn release_download_regex() -> &'static Regex {
149    static RE: OnceLock<Regex> = OnceLock::new();
150    RE.get_or_init(|| {
151        Regex::new(r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/releases/download/(?P<tag>[^/]+)/(?P<filename>.+)$")
152            .unwrap()
153    })
154}
155
156impl TryFrom<&str> for Resource {
157    type Error = ConversionError;
158
159    fn try_from(value: &str) -> Result<Self, Self::Error> {
160        let value = value.trim();
161
162        // Try to match raw file URL: https://github.com/owner/repo/raw/ref/path
163        if let Some(captures) = raw_file_regex().captures(value) {
164            let owner = captures["owner"].to_string();
165            let repo = captures["repo"].to_string();
166            let rest = &captures["rest"];
167
168            // Split the rest to separate reference and path
169            // We need to handle cases like:
170            // - "main/file.sh" -> reference: "main", path: "file.sh"
171            // - "refs/heads/main/file.sh" -> reference: "refs/heads/main", path: "file.sh"
172            let (reference, path) = split_reference_and_path(rest)?;
173
174            return Ok(Resource::File {
175                owner,
176                repo,
177                reference,
178                path,
179            });
180        }
181
182        // Try to match blob file URL: https://github.com/owner/repo/blob/ref/path
183        if let Some(captures) = blob_file_regex().captures(value) {
184            let owner = captures["owner"].to_string();
185            let repo = captures["repo"].to_string();
186            let rest = &captures["rest"];
187
188            let (reference, path) = split_reference_and_path(rest)?;
189
190            return Ok(Resource::File {
191                owner,
192                repo,
193                reference,
194                path,
195            });
196        }
197
198        // Try to match release download URL: https://github.com/owner/repo/releases/download/tag/filename
199        if let Some(captures) = release_download_regex().captures(value) {
200            return Ok(Resource::Release {
201                owner: captures["owner"].to_string(),
202                repo: captures["repo"].to_string(),
203                tag: captures["tag"].to_string(),
204                name: captures["filename"].to_string(),
205            });
206        }
207
208        Err(ConversionError::InvalidUrl(value.to_string()))
209    }
210}
211
212/// Split the rest of the URL into reference and path
213/// Handles cases like:
214/// - "main/file.sh" -> ("main", "file.sh")
215/// - "refs/heads/main/file.sh" -> ("refs/heads/main", "file.sh")
216/// - "refs/tags/v1.0/file.sh" -> ("refs/tags/v1.0", "file.sh")
217fn split_reference_and_path(rest: &str) -> Result<(String, String), ConversionError> {
218    let parts: Vec<&str> = rest.split('/').collect();
219
220    if parts.is_empty() {
221        return Err(ConversionError::ParseError(
222            "Missing reference and path".to_string(),
223        ));
224    }
225
226    // Check if it starts with "refs/"
227    if parts.len() >= 4 && parts[0] == "refs" {
228        // Pattern: refs/heads/main/path or refs/tags/v1.0/path
229        let reference = format!("{}/{}/{}", parts[0], parts[1], parts[2]);
230        let path = parts[3..].join("/");
231
232        if path.is_empty() {
233            return Err(ConversionError::ParseError("Missing file path".to_string()));
234        }
235
236        Ok((reference, path))
237    } else if parts.len() >= 2 {
238        // Pattern: main/path or v1.0/path
239        let reference = parts[0].to_string();
240        let path = parts[1..].join("/");
241        Ok((reference, path))
242    } else {
243        Err(ConversionError::ParseError(
244            "Invalid reference/path format".to_string(),
245        ))
246    }
247}
248
249impl TryFrom<String> for Resource {
250    type Error = ConversionError;
251
252    fn try_from(value: String) -> Result<Self, Self::Error> {
253        Resource::try_from(value.as_str())
254    }
255}