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