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