lux_lib/lua_rockspec/
rock_source.rs

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