Skip to main content

lux_lib/git/
shorthand.rs

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/// Helper for parsing Git URLs from shorthands, e.g. "gitlab:owner/repo"
19#[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                // fall back to parsing the URL directly
44                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
105// A parser that expects a prefix
106fn 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
139// A more lenient parser that defaults to github: if there is not prefix
140fn parser<'a>(
141) -> impl Parser<'a, &'a str, RemoteGitUrlShorthand, chumsky::extra::Err<Rich<'a, char>>> {
142    let git_host_prefix = just(GITHUB)
143        .or(just(GITLAB).or(just(SOURCEHUT).or(just(CODEBERG))))
144        .then_ignore(just(":"))
145        .or_not()
146        .map(|prefix| match prefix {
147            Some(GITHUB) => GitHost::Github,
148            Some(GITLAB) => GitHost::Gitlab,
149            Some(SOURCEHUT) => GitHost::Sourcehut,
150            Some(CODEBERG) => GitHost::Codeberg,
151            _ => GitHost::default(),
152        });
153    let owner_repo = none_of('/')
154        .repeated()
155        .collect::<String>()
156        .separated_by(just('/'))
157        .exactly(2)
158        .collect::<Vec<String>>()
159        .map(to_tuple);
160    git_host_prefix
161        .then(owner_repo)
162        .try_map(|(host, (owner, repo)), span| {
163            let url = url_from_git_host(host, owner, repo).map_err(|err| {
164                Rich::custom(span, format!("error parsing git url shorthand: {err}"))
165            })?;
166            Ok(url)
167        })
168}
169
170impl Display for RemoteGitUrlShorthand {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        if self.0.host == "github.com" {
173            format!("{}:{}/{}", GITHUB, self.0.owner, self.0.repo)
174        } else if self.0.host == "gitlab.com" {
175            format!("{}:{}/{}", GITLAB, self.0.owner, self.0.repo)
176        } else if self.0.host == "git.sr.ht" {
177            format!(
178                "{}:{}/{}",
179                SOURCEHUT,
180                self.0.owner.replace('~', ""),
181                self.0.repo
182            )
183        } else if self.0.host == "codeberg.org" {
184            format!(
185                "{}:{}/{}",
186                CODEBERG,
187                self.0.owner.replace('~', ""),
188                self.0.repo
189            )
190        } else {
191            format!("{}", self.0)
192        }
193        .fmt(f)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[tokio::test]
202    async fn owner_repo_shorthand() {
203        let url_shorthand: RemoteGitUrlShorthand = "lumen-oss/lux".parse().unwrap();
204        assert_eq!(url_shorthand.0.owner, "lumen-oss".to_string());
205        assert_eq!(url_shorthand.0.repo, "lux".to_string());
206    }
207
208    #[tokio::test]
209    async fn github_shorthand() {
210        let url_shorthand_str = "github:lumen-oss/lux";
211        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
212        assert_eq!(url_shorthand.0.host, "github.com".to_string());
213        assert_eq!(url_shorthand.0.owner, "lumen-oss".to_string());
214        assert_eq!(url_shorthand.0.repo, "lux".to_string());
215        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
216    }
217
218    #[tokio::test]
219    async fn gitlab_shorthand() {
220        let url_shorthand_str = "gitlab:lumen-oss/lux";
221        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
222        assert_eq!(url_shorthand.0.host, "gitlab.com".to_string());
223        assert_eq!(url_shorthand.0.owner, "lumen-oss".to_string());
224        assert_eq!(url_shorthand.0.repo, "lux".to_string());
225        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
226    }
227
228    #[tokio::test]
229    async fn sourcehut_shorthand() {
230        let url_shorthand_str = "sourcehut:lumen-oss/lux";
231        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
232        assert_eq!(url_shorthand.0.host, "git.sr.ht".to_string());
233        assert_eq!(url_shorthand.0.owner, "~lumen-oss".to_string());
234        assert_eq!(url_shorthand.0.repo, "lux".to_string());
235        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
236    }
237
238    #[tokio::test]
239    async fn codeberg_shorthand() {
240        let url_shorthand_str = "codeberg:lumen-oss/lux";
241        let url_shorthand: RemoteGitUrlShorthand = url_shorthand_str.parse().unwrap();
242        assert_eq!(url_shorthand.0.host, "codeberg.org".to_string());
243        assert_eq!(url_shorthand.0.owner, "~lumen-oss".to_string());
244        assert_eq!(url_shorthand.0.repo, "lux".to_string());
245        assert_eq!(url_shorthand.to_string(), url_shorthand_str.to_string());
246    }
247
248    #[tokio::test]
249    async fn regular_https_url() {
250        let url_shorthand: RemoteGitUrlShorthand =
251            "https://github.com/lumen-oss/lux.git".parse().unwrap();
252        assert_eq!(url_shorthand.0.host, "github.com".to_string());
253        assert_eq!(url_shorthand.0.owner, "lumen-oss".to_string());
254        assert_eq!(url_shorthand.0.repo, "lux".to_string());
255        assert_eq!(
256            url_shorthand.to_string(),
257            "github:lumen-oss/lux".to_string()
258        );
259    }
260
261    #[tokio::test]
262    async fn regular_ssh_url() {
263        let url_str = "git@github.com:lumen-oss/lux.git";
264        let url_shorthand: RemoteGitUrlShorthand = url_str.parse().unwrap();
265        assert_eq!(url_shorthand.0.host, "github.com".to_string());
266        assert_eq!(
267            url_shorthand.0.owner,
268            "git@github.com:lumen-oss".to_string(),
269        );
270        assert_eq!(url_shorthand.0.repo, "lux".to_string());
271    }
272
273    #[tokio::test]
274    async fn parse_with_prefix() {
275        RemoteGitUrlShorthand::parse_with_prefix("lumen-oss/lux").unwrap_err();
276        RemoteGitUrlShorthand::parse_with_prefix("github:lumen-oss/lux").unwrap();
277        RemoteGitUrlShorthand::parse_with_prefix("gitlab:lumen-oss/lux").unwrap();
278        RemoteGitUrlShorthand::parse_with_prefix("sourcehut:lumen-oss/lux").unwrap();
279        RemoteGitUrlShorthand::parse_with_prefix("codeberg:lumen-oss/lux").unwrap();
280        RemoteGitUrlShorthand::parse_with_prefix("bla:lumen-oss/lux").unwrap_err();
281    }
282}