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#[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)]
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 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#[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#[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#[derive(Debug, PartialEq, Clone)]
222pub(crate) enum SourceUrl {
223 File(PathBuf),
225 Url(Url),
227 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 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}