Skip to main content

lux_lib/lua_rockspec/
rock_source.rs

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