1use std::{fmt::Display, str::FromStr};
2
3use chumsky::{prelude::*, Parser};
4use serde::{de, Deserialize, Deserializer};
5use thiserror::Error;
6
7use crate::git::url::{RemoteGitUrl, RemoteGitUrlParseError};
8
9const GITHUB: &str = "github";
10const GITLAB: &str = "gitlab";
11const SOURCEHUT: &str = "sourcehut";
12const CODEBERG: &str = "codeberg";
13
14#[derive(Debug, Error)]
15#[error("error parsing git source: {0:#?}")]
16pub struct ParseError(Vec<String>);
17
18#[derive(Debug, Clone)]
20pub struct RemoteGitUrlShorthand(RemoteGitUrl);
21
22impl RemoteGitUrlShorthand {
23 pub fn parse_with_prefix(s: &str) -> Result<Self, ParseError> {
24 prefix_parser()
25 .parse(s)
26 .into_result()
27 .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
28 }
29 pub fn repo_name() {}
30}
31
32impl FromStr for RemoteGitUrlShorthand {
33 type Err = ParseError;
34
35 fn from_str(s: &str) -> Result<Self, Self::Err> {
36 match parser()
37 .parse(s)
38 .into_result()
39 .map_err(|err| ParseError(err.into_iter().map(|e| e.to_string()).collect()))
40 {
41 Ok(url) => Ok(url),
42 Err(err) => match s.parse() {
43 Ok(url) => Ok(Self(url)),
45 Err(_) => Err(err),
46 },
47 }
48 }
49}
50
51impl<'de> Deserialize<'de> for RemoteGitUrlShorthand {
52 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 String::deserialize(deserializer)?
57 .parse()
58 .map_err(de::Error::custom)
59 }
60}
61
62impl From<RemoteGitUrl> for RemoteGitUrlShorthand {
63 fn from(value: RemoteGitUrl) -> Self {
64 Self(value)
65 }
66}
67
68impl From<RemoteGitUrlShorthand> for RemoteGitUrl {
69 fn from(value: RemoteGitUrlShorthand) -> Self {
70 value.0
71 }
72}
73
74#[derive(Debug, Default)]
75enum GitHost {
76 #[default]
77 Github,
78 Gitlab,
79 Sourcehut,
80 Codeberg,
81}
82
83fn url_from_git_host(
84 host: GitHost,
85 owner: String,
86 repo: String,
87) -> Result<RemoteGitUrlShorthand, RemoteGitUrlParseError> {
88 let url_str = match host {
89 GitHost::Github => format!("https://github.com/{owner}/{repo}.git"),
90 GitHost::Gitlab => format!("https://gitlab.com/{owner}/{repo}.git"),
91 GitHost::Sourcehut => format!("https://git.sr.ht/~{owner}/{repo}"),
92 GitHost::Codeberg => format!("https://codeberg.org/~{owner}/{repo}.git"),
93 };
94 let url = url_str.parse()?;
95 Ok(RemoteGitUrlShorthand(url))
96}
97
98fn to_tuple<T>(v: Vec<T>) -> (T, T)
99where
100 T: Clone,
101{
102 (v[0].clone(), v[1].clone())
103}
104
105fn prefix_parser<'a>(
107) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
108 let git_host_prefix = just(GITHUB)
109 .or(just(GITLAB).or(just(SOURCEHUT).or(just(CODEBERG))))
110 .then_ignore(just(":"))
111 .map(|prefix| match prefix {
112 GITHUB => GitHost::Github,
113 GITLAB => GitHost::Gitlab,
114 SOURCEHUT => GitHost::Sourcehut,
115 CODEBERG => GitHost::Codeberg,
116 _ => unreachable!(),
117 })
118 .map_err(|err: Rich<'a, char>| {
119 let span = *err.span();
120 Rich::custom(span, "missing git host prefix. Expected 'github:', 'gitlab:', 'sourcehut:' or 'codeberg:'.")
121 });
122 let owner_repo = none_of('/')
123 .repeated()
124 .collect::<String>()
125 .separated_by(just('/'))
126 .exactly(2)
127 .collect::<Vec<String>>()
128 .map(to_tuple);
129 git_host_prefix
130 .then(owner_repo)
131 .try_map(|(host, (owner, repo)), span| {
132 let url = url_from_git_host(host, owner, repo).map_err(|err| {
133 Rich::custom(span, format!("error parsing git url shorthand: {err}"))
134 })?;
135 Ok(url)
136 })
137}
138
139fn scp_style_parser<'a>(
141) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
142 none_of(":/")
143 .repeated()
144 .collect::<String>()
145 .then(
146 just(':')
147 .ignore_then(just("//").not())
148 .ignore_then(any().repeated().collect::<String>()),
149 )
150 .try_map(|(host, path), span| {
151 let inner = format!("ssh://{host}/{path}")
152 .parse::<RemoteGitUrl>()
153 .map_err(|err| {
154 Rich::custom(span, format!("error parsing scp style git url: {err}"))
155 })?;
156 Ok(RemoteGitUrlShorthand(inner))
157 })
158}
159
160fn parser<'a>(
166) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
167 let owner_repo = none_of(":/")
168 .repeated()
169 .collect::<String>()
170 .separated_by(just('/'))
171 .exactly(2)
172 .collect::<Vec<String>>()
173 .map(to_tuple);
174 owner_repo
175 .try_map(|(owner, repo), span| {
176 let url = url_from_git_host(GitHost::default(), owner, repo).map_err(|err| {
177 Rich::custom(span, format!("error parsing git url shorthand: {err}"))
178 })?;
179 Ok(url)
180 })
181 .or(prefix_parser())
182 .or(scp_style_parser())
183}
184
185impl Display for RemoteGitUrlShorthand {
186 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 match (self.0.url.host_str(), self.0.owner()) {
188 (Some("github.com"), Some(owner)) => {
189 format!("{}:{}/{}", GITHUB, owner, self.0.repo())
190 }
191 (Some("gitlab.com"), Some(owner)) => {
192 format!("{}:{}/{}", GITLAB, owner, self.0.repo())
193 }
194 (Some("git.sr.ht"), Some(owner)) => {
195 format!("{}:{}/{}", SOURCEHUT, owner.replace('~', ""), self.0.repo())
196 }
197 (Some("codeberg.org"), Some(owner)) => {
198 format!("{}:{}/{}", CODEBERG, owner.replace('~', ""), self.0.repo())
199 }
200 _ => {
201 format!("{}", self.0)
202 }
203 }
204 .fmt(f)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[tokio::test]
213 async fn owner_repo_shorthand() {
214 let url_shorthand: RemoteGitUrlShorthand = "lumen-oss/lux".parse().unwrap();
215 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
216 assert_eq!(url_shorthand.0.repo(), "lux");
217 }
218
219 #[tokio::test]
220 async fn github_shorthand() {
221 let url_shorthand_str = "github:lumen-oss/lux";
222 let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
223 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
224 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
225 assert_eq!(url_shorthand.0.repo(), "lux");
226 assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
227 }
228
229 #[tokio::test]
230 async fn gitlab_shorthand() {
231 let url_shorthand_str = "gitlab:lumen-oss/lux";
232 let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
233 assert_eq!(url_shorthand.0.url.host_str(), Some("gitlab.com"));
234 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
235 assert_eq!(url_shorthand.0.repo(), "lux");
236 assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
237 }
238
239 #[tokio::test]
240 async fn sourcehut_shorthand() {
241 let url_shorthand_str = "sourcehut:lumen-oss/lux";
242 let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
243 assert_eq!(url_shorthand.0.url.host_str(), Some("git.sr.ht"));
244 assert_eq!(url_shorthand.0.owner(), Some("~lumen-oss"));
245 assert_eq!(url_shorthand.0.repo(), "lux");
246 assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
247 }
248
249 #[tokio::test]
250 async fn codeberg_shorthand() {
251 let url_shorthand_str = "codeberg:lumen-oss/lux";
252 let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
253 assert_eq!(url_shorthand.0.url.host_str(), Some("codeberg.org"));
254 assert_eq!(url_shorthand.0.owner(), Some("~lumen-oss"));
255 assert_eq!(url_shorthand.0.repo(), "lux");
256 assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
257 }
258
259 #[tokio::test]
260 async fn regular_https_url() {
261 let url_shorthand: RemoteGitUrlShorthand =
262 "https://github.com/lumen-oss/lux.git".parse().unwrap();
263 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
264 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
265 assert_eq!(url_shorthand.0.repo(), "lux");
266 assert_eq!(
267 url_shorthand.to_string(),
268 "github:lumen-oss/lux".to_string()
269 );
270 }
271
272 #[tokio::test]
273 async fn regular_http_url() {
274 let url_shorthand: RemoteGitUrlShorthand =
275 "http://github.com/lumen-oss/lux.git".parse().unwrap();
276 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
277 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
278 assert_eq!(url_shorthand.0.repo(), "lux");
279 assert_eq!(
280 url_shorthand.to_string(),
281 "github:lumen-oss/lux".to_string()
282 );
283 }
284
285 #[tokio::test]
286 async fn regular_ssh_url() {
287 let url_shorthand: RemoteGitUrlShorthand =
288 "ssh://git@github.com/lumen-oss/lux.git".parse().unwrap();
289 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
290 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
291 assert_eq!(url_shorthand.0.repo(), "lux");
292 assert_eq!(
293 url_shorthand.to_string(),
294 "github:lumen-oss/lux".to_string()
295 );
296 }
297
298 #[tokio::test]
299 async fn regular_ftp_url() {
300 let url_shorthand: RemoteGitUrlShorthand =
301 "ftp://github.com/lumen-oss/lux.git".parse().unwrap();
302 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
303 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
304 assert_eq!(url_shorthand.0.repo(), "lux");
305 assert_eq!(
306 url_shorthand.to_string(),
307 "github:lumen-oss/lux".to_string()
308 );
309 }
310
311 #[tokio::test]
312 async fn regular_ftps_url() {
313 let url_shorthand: RemoteGitUrlShorthand =
314 "ftps://github.com/lumen-oss/lux.git".parse().unwrap();
315 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
316 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
317 assert_eq!(url_shorthand.0.repo(), "lux");
318 assert_eq!(
319 url_shorthand.to_string(),
320 "github:lumen-oss/lux".to_string()
321 );
322 }
323
324 #[tokio::test]
325 async fn illegal_scheme_url() {
326 RemoteGitUrlShorthand::from_str("git+https://github.com/lumen-oss/lux.git")
327 .expect_err("git+ handling should be done in an outer layer.");
328 RemoteGitUrlShorthand::from_str("file:///lumen-oss/lux.git")
329 .expect_err("local filesystems are not supported as a Remote URL.");
330 RemoteGitUrlShorthand::from_str("xyz:///lumen-oss/lux.git")
331 .expect_err("Unknown schemes are rejected");
332 }
333
334 #[tokio::test]
335 async fn git_scheme_url() {
336 let url_shorthand: RemoteGitUrlShorthand =
337 "git://git@github.com/lumen-oss/lux.git".parse().unwrap();
338 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
339 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
340 assert_eq!(url_shorthand.0.repo(), "lux");
341 assert_eq!(
342 url_shorthand.to_string(),
343 "github:lumen-oss/lux".to_string()
344 );
345 }
346
347 #[tokio::test]
348 async fn scp_style_url() {
349 let url_str = "git@github.com:lumen-oss/lux.git";
350 let url_shorthand: RemoteGitUrlShorthand = url_str.parse().unwrap();
351 assert_eq!(url_shorthand.0.url.host_str(), Some("github.com"));
352 assert_eq!(url_shorthand.0.owner(), Some("lumen-oss"));
353 assert_eq!(url_shorthand.0.repo(), "lux");
354 }
355
356 #[tokio::test]
357 async fn parse_with_prefix() {
358 RemoteGitUrlShorthand::parse_with_prefix("lumen-oss/lux").unwrap_err();
359 RemoteGitUrlShorthand::parse_with_prefix("github:lumen-oss/lux").unwrap();
360 RemoteGitUrlShorthand::parse_with_prefix("gitlab:lumen-oss/lux").unwrap();
361 RemoteGitUrlShorthand::parse_with_prefix("sourcehut:lumen-oss/lux").unwrap();
362 RemoteGitUrlShorthand::parse_with_prefix("codeberg:lumen-oss/lux").unwrap();
363 RemoteGitUrlShorthand::parse_with_prefix("bla:lumen-oss/lux").unwrap_err();
364 }
365}