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