Skip to main content

lux_lib/lua_rockspec/
rock_source.rs

1use path_slash::PathBufExt;
2use reqwest::Url;
3use serde::{de, Deserialize, Deserializer};
4use std::{convert::Infallible, fs, io, ops::Deref, path::PathBuf, str::FromStr};
5use thiserror::Error;
6
7use crate::{
8    git::{
9        url::{RemoteGitUrl, RemoteGitUrlParseError},
10        GitSource,
11    },
12    lua_rockspec::per_platform_from_intermediate,
13};
14
15use super::{
16    DisplayAsLuaKV, DisplayLuaKV, DisplayLuaValue, PartialOverride, PerPlatform,
17    PlatformOverridable,
18};
19
20#[derive(Default, Deserialize, Clone, Debug, PartialEq)]
21pub struct LocalRockSource {
22    pub archive_name: Option<PathBuf>,
23    pub unpack_dir: Option<PathBuf>,
24}
25
26#[derive(Deserialize, Clone, Debug, PartialEq)]
27pub struct RemoteRockSource {
28    pub(crate) local: LocalRockSource,
29    pub source_spec: RockSourceSpec,
30}
31
32impl From<RockSourceSpec> for RemoteRockSource {
33    fn from(source_spec: RockSourceSpec) -> Self {
34        Self {
35            local: LocalRockSource::default(),
36            source_spec,
37        }
38    }
39}
40
41impl Deref for RemoteRockSource {
42    type Target = LocalRockSource;
43
44    fn deref(&self) -> &Self::Target {
45        &self.local
46    }
47}
48
49#[derive(Error, Debug)]
50pub enum RockSourceError {
51    #[error("invalid rockspec source field combination")]
52    InvalidCombination,
53    #[error(transparent)]
54    SourceUrl(#[from] SourceUrlError),
55    #[error("source URL missing")]
56    SourceUrlMissing,
57}
58
59impl From<RockSourceInternal> for LocalRockSource {
60    fn from(internal: RockSourceInternal) -> Self {
61        LocalRockSource {
62            archive_name: internal.file,
63            unpack_dir: internal.dir,
64        }
65    }
66}
67
68impl TryFrom<RockSourceInternal> for RemoteRockSource {
69    type Error = RockSourceError;
70
71    fn try_from(internal: RockSourceInternal) -> Result<Self, Self::Error> {
72        let local = LocalRockSource::from(internal.clone());
73
74        // The rockspec.source table allows invalid combinations
75        // This ensures that invalid combinations are caught while parsing.
76        let url = SourceUrl::from_str(&internal.url.ok_or(RockSourceError::SourceUrlMissing)?)?;
77
78        let source_spec = match (url, internal.tag, internal.branch) {
79            (source, None, None) => Ok(RockSourceSpec::default_from_source_url(source)),
80            (SourceUrl::Git(url), Some(tag), None) => Ok(RockSourceSpec::Git(GitSource {
81                url,
82                checkout_ref: Some(tag),
83            })),
84            (SourceUrl::Git(url), None, Some(branch)) => Ok(RockSourceSpec::Git(GitSource {
85                url,
86                checkout_ref: Some(branch),
87            })),
88            _ => Err(RockSourceError::InvalidCombination),
89        }?;
90
91        Ok(RemoteRockSource { source_spec, local })
92    }
93}
94
95impl<'de> Deserialize<'de> for PerPlatform<RemoteRockSource> {
96    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
97    where
98        D: Deserializer<'de>,
99    {
100        per_platform_from_intermediate::<_, RockSourceInternal, _>(deserializer)
101    }
102}
103
104#[derive(Debug, PartialEq, Clone)]
105pub enum RockSourceSpec {
106    Git(GitSource),
107    File(PathBuf),
108    Url(Url),
109}
110
111impl RockSourceSpec {
112    fn default_from_source_url(url: SourceUrl) -> Self {
113        match url {
114            SourceUrl::File(path) => Self::File(path),
115            SourceUrl::Url(url) => Self::Url(url),
116            SourceUrl::Git(url) => Self::Git(GitSource {
117                url,
118                checkout_ref: None,
119            }),
120        }
121    }
122}
123
124impl<'de> Deserialize<'de> for RockSourceSpec {
125    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
126    where
127        D: Deserializer<'de>,
128    {
129        let url = String::deserialize(deserializer)?;
130        Ok(RockSourceSpec::default_from_source_url(
131            url.parse().map_err(de::Error::custom)?,
132        ))
133    }
134}
135
136impl DisplayAsLuaKV for RockSourceSpec {
137    fn display_lua(&self) -> DisplayLuaKV {
138        match self {
139            RockSourceSpec::Git(git_source) => git_source.display_lua(),
140            RockSourceSpec::File(path) => {
141                let mut source_tbl = Vec::new();
142                source_tbl.push(DisplayLuaKV {
143                    key: "url".to_string(),
144                    value: DisplayLuaValue::String(format!("file:://{}", path.display())),
145                });
146                DisplayLuaKV {
147                    key: "source".to_string(),
148                    value: DisplayLuaValue::Table(source_tbl),
149                }
150            }
151            RockSourceSpec::Url(url) => {
152                let mut source_tbl = Vec::new();
153                source_tbl.push(DisplayLuaKV {
154                    key: "url".to_string(),
155                    value: DisplayLuaValue::String(format!("{url}")),
156                });
157                DisplayLuaKV {
158                    key: "source".to_string(),
159                    value: DisplayLuaValue::Table(source_tbl),
160                }
161            }
162        }
163    }
164}
165
166/// Used as a helper for Deserialize,
167/// because the Rockspec schema allows invalid rockspecs (╯°□°)╯︵ ┻━┻
168#[derive(Debug, PartialEq, Deserialize, Clone, Default)]
169pub(crate) struct RockSourceInternal {
170    #[serde(default)]
171    pub(crate) url: Option<String>,
172    pub(crate) file: Option<PathBuf>,
173    pub(crate) dir: Option<PathBuf>,
174    pub(crate) tag: Option<String>,
175    pub(crate) branch: Option<String>,
176}
177
178impl PartialOverride for RockSourceInternal {
179    type Err = Infallible;
180
181    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
182        Ok(Self {
183            url: override_opt(override_spec.url.as_ref(), self.url.as_ref()),
184            file: override_opt(override_spec.file.as_ref(), self.file.as_ref()),
185            dir: override_opt(override_spec.dir.as_ref(), self.dir.as_ref()),
186            tag: match &override_spec.branch {
187                None => override_opt(override_spec.tag.as_ref(), self.tag.as_ref()),
188                _ => None,
189            },
190            branch: match &override_spec.tag {
191                None => override_opt(override_spec.branch.as_ref(), self.branch.as_ref()),
192                _ => None,
193            },
194        })
195    }
196}
197
198impl DisplayAsLuaKV for RockSourceInternal {
199    fn display_lua(&self) -> DisplayLuaKV {
200        let mut result = Vec::new();
201
202        if let Some(url) = &self.url {
203            result.push(DisplayLuaKV {
204                key: "url".to_string(),
205                value: DisplayLuaValue::String(url.clone()),
206            });
207        }
208        if let Some(file) = &self.file {
209            result.push(DisplayLuaKV {
210                key: "file".to_string(),
211                value: DisplayLuaValue::String(file.to_slash_lossy().to_string()),
212            });
213        }
214        if let Some(dir) = &self.dir {
215            result.push(DisplayLuaKV {
216                key: "dir".to_string(),
217                value: DisplayLuaValue::String(dir.to_slash_lossy().to_string()),
218            });
219        }
220        if let Some(tag) = &self.tag {
221            result.push(DisplayLuaKV {
222                key: "tag".to_string(),
223                value: DisplayLuaValue::String(tag.clone()),
224            });
225        }
226        if let Some(branch) = &self.branch {
227            result.push(DisplayLuaKV {
228                key: "branch".to_string(),
229                value: DisplayLuaValue::String(branch.clone()),
230            });
231        }
232
233        DisplayLuaKV {
234            key: "source".to_string(),
235            value: DisplayLuaValue::Table(result),
236        }
237    }
238}
239
240#[derive(Error, Debug)]
241#[error("missing source")]
242pub struct RockSourceMissingSource;
243
244impl PlatformOverridable for RockSourceInternal {
245    type Err = RockSourceMissingSource;
246
247    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
248    where
249        T: PlatformOverridable,
250    {
251        Err(RockSourceMissingSource)
252    }
253}
254
255fn override_opt<T: Clone>(override_opt: Option<&T>, base: Option<&T>) -> Option<T> {
256    override_opt.or(base).cloned()
257}
258
259/// Internal helper for parsing
260#[derive(Debug, PartialEq, Clone)]
261pub(crate) enum SourceUrl {
262    /// For URLs in the local filesystem
263    File(PathBuf),
264    /// Web URLs
265    Url(Url),
266    /// For the Git source control manager
267    Git(RemoteGitUrl),
268}
269
270#[derive(Error, Debug)]
271#[error("failed to parse source url: {0}")]
272pub enum SourceUrlError {
273    Io(#[from] io::Error),
274    Git(#[from] RemoteGitUrlParseError),
275    Url(#[source] <Url as FromStr>::Err),
276    #[error("lux does not support rockspecs with CVS sources.")]
277    CVS,
278    #[error("lux does not support rockspecs with mercurial sources.")]
279    Mercurial,
280    #[error("lux does not support rockspecs with SSCM sources.")]
281    SSCM,
282    #[error("lux does not support rockspecs with SVN sources.")]
283    SVN,
284    #[error("unsupported source URL prefix: '{0}+' in URL {1}")]
285    UnsupportedPrefix(String, String),
286    #[error("unsupported source URL: {0}")]
287    Unsupported(String),
288}
289
290impl FromStr for SourceUrl {
291    type Err = SourceUrlError;
292
293    fn from_str(str: &str) -> Result<Self, Self::Err> {
294        match str.split_once("+") {
295            Some(("git" | "gitrec", url)) => Ok(Self::Git(url.parse()?)),
296            Some((prefix, _)) => Err(SourceUrlError::UnsupportedPrefix(
297                prefix.to_string(),
298                str.to_string(),
299            )),
300            None => match str {
301                s if s.starts_with("file://") => {
302                    let path_buf: PathBuf = s.trim_start_matches("file://").into();
303                    let path = fs::canonicalize(&path_buf)?;
304                    Ok(Self::File(path))
305                }
306                s if s.starts_with("git://") => {
307                    Ok(Self::Git(s.replacen("git", "https", 1).parse()?))
308                }
309                s if s.ends_with(".git") => Ok(Self::Git(s.parse()?)),
310                s if starts_with_any(s, ["https://", "http://", "ftp://"].into()) => {
311                    Ok(Self::Url(s.parse().map_err(SourceUrlError::Url)?))
312                }
313                s if s.starts_with("cvs://") => Err(SourceUrlError::CVS),
314                s if starts_with_any(
315                    s,
316                    ["hg://", "hg+http://", "hg+https://", "hg+ssh://"].into(),
317                ) =>
318                {
319                    Err(SourceUrlError::Mercurial)
320                }
321                s if s.starts_with("sscm://") => Err(SourceUrlError::SSCM),
322                s if s.starts_with("svn://") => Err(SourceUrlError::SVN),
323                s => Err(SourceUrlError::Unsupported(s.to_string())),
324            },
325        }
326    }
327}
328
329impl<'de> Deserialize<'de> for SourceUrl {
330    fn deserialize<D>(deserializer: D) -> Result<SourceUrl, D::Error>
331    where
332        D: Deserializer<'de>,
333    {
334        SourceUrl::from_str(&String::deserialize(deserializer)?).map_err(de::Error::custom)
335    }
336}
337
338fn starts_with_any(str: &str, prefixes: Vec<&str>) -> bool {
339    prefixes.iter().any(|&prefix| str.starts_with(prefix))
340}
341
342#[cfg(test)]
343mod tests {
344
345    use assert_fs::TempDir;
346
347    use super::*;
348
349    #[tokio::test]
350    async fn parse_source_url() {
351        let dir = TempDir::new().unwrap();
352        let url: SourceUrl = format!("file://{}", dir.to_string_lossy()).parse().unwrap();
353        assert_eq!(url, SourceUrl::File(dir.path().to_path_buf()));
354        let url: SourceUrl = "ftp://example.com/foo/bar".parse().unwrap();
355        assert!(matches!(url, SourceUrl::Url { .. }));
356        let url: SourceUrl = "git://example.com/foo/bar".parse().unwrap();
357        assert!(matches!(url, SourceUrl::Git { .. }));
358        // We don't support file-like URLs, as they are not remote.
359        SourceUrl::from_str("git+file:///path/to/repo.git").unwrap_err();
360        let url: SourceUrl = "git+http://example.com/foo/bar".parse().unwrap();
361        assert!(matches!(url, SourceUrl::Git { .. }));
362        let url: SourceUrl = "git+https://example.com/foo/bar".parse().unwrap();
363        assert!(matches!(url, SourceUrl::Git { .. }));
364        let url: SourceUrl = "git+ssh://example.com/foo/bar".parse().unwrap();
365        assert!(matches!(url, SourceUrl::Git { .. }));
366        let url: SourceUrl = "gitrec+https://example.com/foo/bar".parse().unwrap();
367        assert!(matches!(url, SourceUrl::Git { .. }));
368        let url: SourceUrl = "https://example.com/foo/bar".parse().unwrap();
369        assert!(matches!(url, SourceUrl::Url { .. }));
370        let url: SourceUrl = "http://example.com/foo/bar".parse().unwrap();
371        assert!(matches!(url, SourceUrl::Url { .. }));
372    }
373}