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)]
51pub enum RockSourceError {
52    #[error("invalid rockspec source field combination")]
53    InvalidCombination,
54    #[error(transparent)]
55    #[diagnostic(transparent)]
56    SourceUrl(#[from] SourceUrlError),
57    #[error("source URL missing")]
58    SourceUrlMissing,
59}
60
61impl From<RockSourceInternal> for LocalRockSource {
62    fn from(internal: RockSourceInternal) -> Self {
63        LocalRockSource {
64            archive_name: internal.file,
65            unpack_dir: internal.dir,
66        }
67    }
68}
69
70impl TryFrom<RockSourceInternal> for RemoteRockSource {
71    type Error = RockSourceError;
72
73    fn try_from(internal: RockSourceInternal) -> Result<Self, Self::Error> {
74        let local = LocalRockSource::from(internal.clone());
75
76        // The rockspec.source table allows invalid combinations
77        // This ensures that invalid combinations are caught while parsing.
78        let url = SourceUrl::from_str(&internal.url.ok_or(RockSourceError::SourceUrlMissing)?)?;
79
80        let source_spec = match (url, internal.tag, internal.branch) {
81            (source, None, None) => Ok(RockSourceSpec::default_from_source_url(source)),
82            (SourceUrl::Git(url), Some(tag), None) => Ok(RockSourceSpec::Git(GitSource {
83                url,
84                checkout_ref: Some(tag),
85            })),
86            (SourceUrl::Git(url), None, Some(branch)) => Ok(RockSourceSpec::Git(GitSource {
87                url,
88                checkout_ref: Some(branch),
89            })),
90            _ => Err(RockSourceError::InvalidCombination),
91        }?;
92
93        Ok(RemoteRockSource { source_spec, local })
94    }
95}
96
97impl<'de> Deserialize<'de> for PerPlatform<RemoteRockSource> {
98    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99    where
100        D: Deserializer<'de>,
101    {
102        per_platform_from_intermediate::<_, RockSourceInternal, _>(deserializer)
103    }
104}
105
106/// Specifies a rock's source
107#[derive(Debug, PartialEq, Eq, Hash, Clone)]
108pub enum RockSourceSpec {
109    Git(GitSource),
110    File(PathBuf),
111    Url(Url),
112}
113
114impl RockSourceSpec {
115    fn default_from_source_url(url: SourceUrl) -> Self {
116        match url {
117            SourceUrl::File(path) => Self::File(path),
118            SourceUrl::Url(url) => Self::Url(url),
119            SourceUrl::Git(url) => Self::Git(GitSource {
120                url,
121                checkout_ref: None,
122            }),
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        Ok(Self {
197            url: override_opt(override_spec.url.as_ref(), self.url.as_ref()),
198            file: override_opt(override_spec.file.as_ref(), self.file.as_ref()),
199            dir: override_opt(override_spec.dir.as_ref(), self.dir.as_ref()),
200            tag: match &override_spec.branch {
201                None => override_opt(override_spec.tag.as_ref(), self.tag.as_ref()),
202                _ => None,
203            },
204            branch: match &override_spec.tag {
205                None => override_opt(override_spec.branch.as_ref(), self.branch.as_ref()),
206                _ => None,
207            },
208        })
209    }
210}
211
212#[derive(Error, Debug, Diagnostic)]
213#[error("missing source")]
214pub struct RockSourceMissingSource;
215
216impl PlatformOverridable for RockSourceInternal {
217    type Err = RockSourceMissingSource;
218
219    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
220    where
221        T: PlatformOverridable,
222    {
223        Err(RockSourceMissingSource)
224    }
225}
226
227fn override_opt<T: Clone>(override_opt: Option<&T>, base: Option<&T>) -> Option<T> {
228    override_opt.or(base).cloned()
229}
230
231/// Internal helper for parsing
232#[derive(Debug, PartialEq, Clone)]
233pub(crate) enum SourceUrl {
234    /// For URLs in the local filesystem
235    File(PathBuf),
236    /// Web URLs
237    Url(Url),
238    /// For the Git source control manager
239    Git(RemoteGitUrl),
240}
241
242#[derive(Error, Debug, Diagnostic)]
243#[error("failed to parse source url: {0}")]
244pub enum SourceUrlError {
245    Io(#[from] io::Error),
246    Git(#[from] RemoteGitUrlParseError),
247    Url(#[source] <Url as FromStr>::Err),
248    #[error("lux does not support rockspecs with CVS sources.")]
249    CVS,
250    #[error("lux does not support rockspecs with mercurial sources.")]
251    Mercurial,
252    #[error("lux does not support rockspecs with SSCM sources.")]
253    SSCM,
254    #[error("lux does not support rockspecs with SVN sources.")]
255    SVN,
256    #[error("unsupported source URL prefix: '{0}+' in URL {1}")]
257    UnsupportedPrefix(String, String),
258    #[error("unsupported source URL: {0}")]
259    Unsupported(String),
260}
261
262impl FromStr for SourceUrl {
263    type Err = SourceUrlError;
264
265    fn from_str(str: &str) -> Result<Self, Self::Err> {
266        match str.split_once("+") {
267            Some(("git" | "gitrec", url)) => Ok(Self::Git(url.parse()?)),
268            Some((prefix, _)) => Err(SourceUrlError::UnsupportedPrefix(
269                prefix.to_string(),
270                str.to_string(),
271            )),
272            None => match str {
273                s if s.starts_with("file://") => {
274                    let path_buf: PathBuf = s.trim_start_matches("file://").into();
275                    let path = fs::canonicalize(&path_buf)?;
276                    Ok(Self::File(path))
277                }
278                s if s.starts_with("git://") => {
279                    Ok(Self::Git(s.replacen("git", "https", 1).parse()?))
280                }
281                s if s.ends_with(".git") => Ok(Self::Git(s.parse()?)),
282                s if starts_with_any(s, ["https://", "http://", "ftp://"].into()) => {
283                    Ok(Self::Url(s.parse().map_err(SourceUrlError::Url)?))
284                }
285                s if s.starts_with("cvs://") => Err(SourceUrlError::CVS),
286                s if starts_with_any(
287                    s,
288                    ["hg://", "hg+http://", "hg+https://", "hg+ssh://"].into(),
289                ) =>
290                {
291                    Err(SourceUrlError::Mercurial)
292                }
293                s if s.starts_with("sscm://") => Err(SourceUrlError::SSCM),
294                s if s.starts_with("svn://") => Err(SourceUrlError::SVN),
295                s => Err(SourceUrlError::Unsupported(s.to_string())),
296            },
297        }
298    }
299}
300
301impl<'de> Deserialize<'de> for SourceUrl {
302    fn deserialize<D>(deserializer: D) -> Result<SourceUrl, D::Error>
303    where
304        D: Deserializer<'de>,
305    {
306        SourceUrl::from_str(&String::deserialize(deserializer)?).map_err(de::Error::custom)
307    }
308}
309
310fn starts_with_any(str: &str, prefixes: Vec<&str>) -> bool {
311    prefixes.iter().any(|&prefix| str.starts_with(prefix))
312}
313
314#[cfg(test)]
315mod tests {
316
317    use assert_fs::TempDir;
318
319    use super::*;
320
321    fn eval_lua_global<T: serde::de::DeserializeOwned>(code: &str, key: &'static str) -> T {
322        use ottavino::{Closure, Executor, Fuel, Lua};
323        use ottavino_util::serde::from_value;
324        Lua::core()
325            .try_enter(|ctx| {
326                let closure = Closure::load(ctx, None, code.as_bytes())?;
327                let executor = Executor::start(ctx, closure.into(), ());
328                executor.step(ctx, &mut Fuel::with(i32::MAX))?;
329                from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
330            })
331            .unwrap()
332    }
333
334    #[test]
335    pub fn rock_source_internal_roundtrip() {
336        let source = RockSourceInternal {
337            url: Some("https://github.com/example/repo/archive/v1.0.tar.gz".into()),
338            file: Some("repo-1.0.tar.gz".into()),
339            dir: Some("repo-1.0".into()),
340            tag: Some("v1.0".into()),
341            branch: None,
342        };
343        let lua = source.display_lua().to_string();
344        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
345        assert_eq!(source, restored);
346    }
347
348    #[test]
349    pub fn rock_source_internal_branch_roundtrip() {
350        let source = RockSourceInternal {
351            url: Some("git+https://github.com/example/repo.git".into()),
352            file: None,
353            dir: None,
354            tag: None,
355            branch: Some("main".into()),
356        };
357        let lua = source.display_lua().to_string();
358        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
359        assert_eq!(source, restored);
360    }
361
362    #[tokio::test]
363    async fn parse_source_url() {
364        let dir = TempDir::new().unwrap();
365        let url: SourceUrl = format!("file://{}", dir.to_string_lossy()).parse().unwrap();
366        assert_eq!(url, SourceUrl::File(dir.path().to_path_buf()));
367        let url: SourceUrl = "ftp://example.com/foo/bar".parse().unwrap();
368        assert!(matches!(url, SourceUrl::Url { .. }));
369        let url: SourceUrl = "git://example.com/foo/bar".parse().unwrap();
370        assert!(matches!(url, SourceUrl::Git { .. }));
371        // We don't support file-like URLs, as they are not remote.
372        SourceUrl::from_str("git+file:///path/to/repo.git").unwrap_err();
373        let url: SourceUrl = "git+http://example.com/foo/bar".parse().unwrap();
374        assert!(matches!(url, SourceUrl::Git { .. }));
375        let url: SourceUrl = "git+https://example.com/foo/bar".parse().unwrap();
376        assert!(matches!(url, SourceUrl::Git { .. }));
377        let url: SourceUrl = "git+ssh://example.com/foo/bar".parse().unwrap();
378        assert!(matches!(url, SourceUrl::Git { .. }));
379        let url: SourceUrl = "gitrec+https://example.com/foo/bar".parse().unwrap();
380        assert!(matches!(url, SourceUrl::Git { .. }));
381        let url: SourceUrl = "https://example.com/foo/bar".parse().unwrap();
382        assert!(matches!(url, SourceUrl::Url { .. }));
383        let url: SourceUrl = "http://example.com/foo/bar".parse().unwrap();
384        assert!(matches!(url, SourceUrl::Url { .. }));
385    }
386}