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, 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<'de> Deserialize<'de> for RockSourceSpec {
128    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let url = String::deserialize(deserializer)?;
133        Ok(RockSourceSpec::default_from_source_url(
134            url.parse().map_err(de::Error::custom)?,
135        ))
136    }
137}
138
139impl DisplayAsLuaKV for RockSourceSpec {
140    fn display_lua(&self) -> DisplayLuaKV {
141        match self {
142            RockSourceSpec::Git(git_source) => git_source.display_lua(),
143            RockSourceSpec::File(path) => {
144                let mut source_tbl = Vec::new();
145                source_tbl.push(DisplayLuaKV {
146                    key: "url".to_string(),
147                    value: DisplayLuaValue::String(format!("file:://{}", path.display())),
148                });
149                DisplayLuaKV {
150                    key: "source".to_string(),
151                    value: DisplayLuaValue::Table(source_tbl),
152                }
153            }
154            RockSourceSpec::Url(url) => {
155                let mut source_tbl = Vec::new();
156                source_tbl.push(DisplayLuaKV {
157                    key: "url".to_string(),
158                    value: DisplayLuaValue::String(format!("{url}")),
159                });
160                DisplayLuaKV {
161                    key: "source".to_string(),
162                    value: DisplayLuaValue::Table(source_tbl),
163                }
164            }
165        }
166    }
167}
168
169/// Used as a helper for Deserialize,
170/// because the Rockspec schema allows invalid rockspecs (╯°□°)╯︵ ┻━┻
171#[derive(Debug, PartialEq, Deserialize, Clone, Default, lux_macros::DisplayAsLuaKV)]
172#[display_lua(key = "source")]
173pub(crate) struct RockSourceInternal {
174    #[serde(default)]
175    pub(crate) url: Option<String>,
176    pub(crate) file: Option<PathBuf>,
177    pub(crate) dir: Option<PathBuf>,
178    pub(crate) tag: Option<String>,
179    pub(crate) branch: Option<String>,
180}
181
182impl PartialOverride for RockSourceInternal {
183    type Err = Infallible;
184
185    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
186        Ok(Self {
187            url: override_opt(override_spec.url.as_ref(), self.url.as_ref()),
188            file: override_opt(override_spec.file.as_ref(), self.file.as_ref()),
189            dir: override_opt(override_spec.dir.as_ref(), self.dir.as_ref()),
190            tag: match &override_spec.branch {
191                None => override_opt(override_spec.tag.as_ref(), self.tag.as_ref()),
192                _ => None,
193            },
194            branch: match &override_spec.tag {
195                None => override_opt(override_spec.branch.as_ref(), self.branch.as_ref()),
196                _ => None,
197            },
198        })
199    }
200}
201
202#[derive(Error, Debug, Diagnostic)]
203#[error("missing source")]
204pub struct RockSourceMissingSource;
205
206impl PlatformOverridable for RockSourceInternal {
207    type Err = RockSourceMissingSource;
208
209    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
210    where
211        T: PlatformOverridable,
212    {
213        Err(RockSourceMissingSource)
214    }
215}
216
217fn override_opt<T: Clone>(override_opt: Option<&T>, base: Option<&T>) -> Option<T> {
218    override_opt.or(base).cloned()
219}
220
221/// Internal helper for parsing
222#[derive(Debug, PartialEq, Clone)]
223pub(crate) enum SourceUrl {
224    /// For URLs in the local filesystem
225    File(PathBuf),
226    /// Web URLs
227    Url(Url),
228    /// For the Git source control manager
229    Git(RemoteGitUrl),
230}
231
232#[derive(Error, Debug, Diagnostic)]
233#[error("failed to parse source url: {0}")]
234pub enum SourceUrlError {
235    Io(#[from] io::Error),
236    Git(#[from] RemoteGitUrlParseError),
237    Url(#[source] <Url as FromStr>::Err),
238    #[error("lux does not support rockspecs with CVS sources.")]
239    CVS,
240    #[error("lux does not support rockspecs with mercurial sources.")]
241    Mercurial,
242    #[error("lux does not support rockspecs with SSCM sources.")]
243    SSCM,
244    #[error("lux does not support rockspecs with SVN sources.")]
245    SVN,
246    #[error("unsupported source URL prefix: '{0}+' in URL {1}")]
247    UnsupportedPrefix(String, String),
248    #[error("unsupported source URL: {0}")]
249    Unsupported(String),
250}
251
252impl FromStr for SourceUrl {
253    type Err = SourceUrlError;
254
255    fn from_str(str: &str) -> Result<Self, Self::Err> {
256        match str.split_once("+") {
257            Some(("git" | "gitrec", url)) => Ok(Self::Git(url.parse()?)),
258            Some((prefix, _)) => Err(SourceUrlError::UnsupportedPrefix(
259                prefix.to_string(),
260                str.to_string(),
261            )),
262            None => match str {
263                s if s.starts_with("file://") => {
264                    let path_buf: PathBuf = s.trim_start_matches("file://").into();
265                    let path = fs::canonicalize(&path_buf)?;
266                    Ok(Self::File(path))
267                }
268                s if s.starts_with("git://") => {
269                    Ok(Self::Git(s.replacen("git", "https", 1).parse()?))
270                }
271                s if s.ends_with(".git") => Ok(Self::Git(s.parse()?)),
272                s if starts_with_any(s, ["https://", "http://", "ftp://"].into()) => {
273                    Ok(Self::Url(s.parse().map_err(SourceUrlError::Url)?))
274                }
275                s if s.starts_with("cvs://") => Err(SourceUrlError::CVS),
276                s if starts_with_any(
277                    s,
278                    ["hg://", "hg+http://", "hg+https://", "hg+ssh://"].into(),
279                ) =>
280                {
281                    Err(SourceUrlError::Mercurial)
282                }
283                s if s.starts_with("sscm://") => Err(SourceUrlError::SSCM),
284                s if s.starts_with("svn://") => Err(SourceUrlError::SVN),
285                s => Err(SourceUrlError::Unsupported(s.to_string())),
286            },
287        }
288    }
289}
290
291impl<'de> Deserialize<'de> for SourceUrl {
292    fn deserialize<D>(deserializer: D) -> Result<SourceUrl, D::Error>
293    where
294        D: Deserializer<'de>,
295    {
296        SourceUrl::from_str(&String::deserialize(deserializer)?).map_err(de::Error::custom)
297    }
298}
299
300fn starts_with_any(str: &str, prefixes: Vec<&str>) -> bool {
301    prefixes.iter().any(|&prefix| str.starts_with(prefix))
302}
303
304#[cfg(test)]
305mod tests {
306
307    use assert_fs::TempDir;
308
309    use super::*;
310
311    fn eval_lua_global<T: serde::de::DeserializeOwned>(code: &str, key: &'static str) -> T {
312        use ottavino::{Closure, Executor, Fuel, Lua};
313        use ottavino_util::serde::from_value;
314        Lua::core()
315            .try_enter(|ctx| {
316                let closure = Closure::load(ctx, None, code.as_bytes())?;
317                let executor = Executor::start(ctx, closure.into(), ());
318                executor.step(ctx, &mut Fuel::with(i32::MAX))?;
319                from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
320            })
321            .unwrap()
322    }
323
324    #[test]
325    pub fn rock_source_internal_roundtrip() {
326        let source = RockSourceInternal {
327            url: Some("https://github.com/example/repo/archive/v1.0.tar.gz".into()),
328            file: Some("repo-1.0.tar.gz".into()),
329            dir: Some("repo-1.0".into()),
330            tag: Some("v1.0".into()),
331            branch: None,
332        };
333        let lua = source.display_lua().to_string();
334        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
335        assert_eq!(source, restored);
336    }
337
338    #[test]
339    pub fn rock_source_internal_branch_roundtrip() {
340        let source = RockSourceInternal {
341            url: Some("git+https://github.com/example/repo.git".into()),
342            file: None,
343            dir: None,
344            tag: None,
345            branch: Some("main".into()),
346        };
347        let lua = source.display_lua().to_string();
348        let restored: RockSourceInternal = eval_lua_global(&lua, "source");
349        assert_eq!(source, restored);
350    }
351
352    #[tokio::test]
353    async fn parse_source_url() {
354        let dir = TempDir::new().unwrap();
355        let url: SourceUrl = format!("file://{}", dir.to_string_lossy()).parse().unwrap();
356        assert_eq!(url, SourceUrl::File(dir.path().to_path_buf()));
357        let url: SourceUrl = "ftp://example.com/foo/bar".parse().unwrap();
358        assert!(matches!(url, SourceUrl::Url { .. }));
359        let url: SourceUrl = "git://example.com/foo/bar".parse().unwrap();
360        assert!(matches!(url, SourceUrl::Git { .. }));
361        // We don't support file-like URLs, as they are not remote.
362        SourceUrl::from_str("git+file:///path/to/repo.git").unwrap_err();
363        let url: SourceUrl = "git+http://example.com/foo/bar".parse().unwrap();
364        assert!(matches!(url, SourceUrl::Git { .. }));
365        let url: SourceUrl = "git+https://example.com/foo/bar".parse().unwrap();
366        assert!(matches!(url, SourceUrl::Git { .. }));
367        let url: SourceUrl = "git+ssh://example.com/foo/bar".parse().unwrap();
368        assert!(matches!(url, SourceUrl::Git { .. }));
369        let url: SourceUrl = "gitrec+https://example.com/foo/bar".parse().unwrap();
370        assert!(matches!(url, SourceUrl::Git { .. }));
371        let url: SourceUrl = "https://example.com/foo/bar".parse().unwrap();
372        assert!(matches!(url, SourceUrl::Url { .. }));
373        let url: SourceUrl = "http://example.com/foo/bar".parse().unwrap();
374        assert!(matches!(url, SourceUrl::Url { .. }));
375    }
376}