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