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