Skip to main content

lux_lib/lua_rockspec/
rock_source.rs

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