lux_lib/lua_rockspec/
rock_source.rs

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