use reqwest::Url;
use serde::{de, Deserialize, Deserializer};
use std::{convert::Infallible, fs, io, ops::Deref, path::PathBuf, str::FromStr};
use thiserror::Error;
use crate::{
git::{
url::{RemoteGitUrl, RemoteGitUrlParseError},
GitSource,
},
lua_rockspec::per_platform_from_intermediate,
};
use super::{
DisplayAsLuaKV, DisplayLuaKV, DisplayLuaValue, PartialOverride, PerPlatform,
PlatformOverridable,
};
#[derive(Default, Deserialize, Clone, Debug, PartialEq)]
pub struct LocalRockSource {
pub archive_name: Option<PathBuf>,
pub unpack_dir: Option<PathBuf>,
}
#[derive(Deserialize, Clone, Debug, PartialEq)]
pub struct RemoteRockSource {
pub(crate) local: LocalRockSource,
pub source_spec: RockSourceSpec,
}
impl From<RockSourceSpec> for RemoteRockSource {
fn from(source_spec: RockSourceSpec) -> Self {
Self {
local: LocalRockSource::default(),
source_spec,
}
}
}
impl Deref for RemoteRockSource {
type Target = LocalRockSource;
fn deref(&self) -> &Self::Target {
&self.local
}
}
#[derive(Error, Debug)]
pub enum RockSourceError {
#[error("invalid rockspec source field combination")]
InvalidCombination,
#[error(transparent)]
SourceUrl(#[from] SourceUrlError),
#[error("source URL missing")]
SourceUrlMissing,
}
impl From<RockSourceInternal> for LocalRockSource {
fn from(internal: RockSourceInternal) -> Self {
LocalRockSource {
archive_name: internal.file,
unpack_dir: internal.dir,
}
}
}
impl TryFrom<RockSourceInternal> for RemoteRockSource {
type Error = RockSourceError;
fn try_from(internal: RockSourceInternal) -> Result<Self, Self::Error> {
let local = LocalRockSource::from(internal.clone());
let url = SourceUrl::from_str(&internal.url.ok_or(RockSourceError::SourceUrlMissing)?)?;
let source_spec = match (url, internal.tag, internal.branch) {
(source, None, None) => Ok(RockSourceSpec::default_from_source_url(source)),
(SourceUrl::Git(url), Some(tag), None) => Ok(RockSourceSpec::Git(GitSource {
url,
checkout_ref: Some(tag),
})),
(SourceUrl::Git(url), None, Some(branch)) => Ok(RockSourceSpec::Git(GitSource {
url,
checkout_ref: Some(branch),
})),
_ => Err(RockSourceError::InvalidCombination),
}?;
Ok(RemoteRockSource { source_spec, local })
}
}
impl<'de> Deserialize<'de> for PerPlatform<RemoteRockSource> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
per_platform_from_intermediate::<_, RockSourceInternal, _>(deserializer)
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum RockSourceSpec {
Git(GitSource),
File(PathBuf),
Url(Url),
}
impl RockSourceSpec {
fn default_from_source_url(url: SourceUrl) -> Self {
match url {
SourceUrl::File(path) => Self::File(path),
SourceUrl::Url(url) => Self::Url(url),
SourceUrl::Git(url) => Self::Git(GitSource {
url,
checkout_ref: None,
}),
}
}
}
impl<'de> Deserialize<'de> for RockSourceSpec {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let url = String::deserialize(deserializer)?;
Ok(RockSourceSpec::default_from_source_url(
url.parse().map_err(de::Error::custom)?,
))
}
}
impl DisplayAsLuaKV for RockSourceSpec {
fn display_lua(&self) -> DisplayLuaKV {
match self {
RockSourceSpec::Git(git_source) => git_source.display_lua(),
RockSourceSpec::File(path) => {
let mut source_tbl = Vec::new();
source_tbl.push(DisplayLuaKV {
key: "url".to_string(),
value: DisplayLuaValue::String(format!("file:://{}", path.display())),
});
DisplayLuaKV {
key: "source".to_string(),
value: DisplayLuaValue::Table(source_tbl),
}
}
RockSourceSpec::Url(url) => {
let mut source_tbl = Vec::new();
source_tbl.push(DisplayLuaKV {
key: "url".to_string(),
value: DisplayLuaValue::String(format!("{url}")),
});
DisplayLuaKV {
key: "source".to_string(),
value: DisplayLuaValue::Table(source_tbl),
}
}
}
}
}
#[derive(Debug, PartialEq, Deserialize, Clone, Default, lux_macros::DisplayAsLuaKV)]
#[display_lua(key = "source")]
pub(crate) struct RockSourceInternal {
#[serde(default)]
pub(crate) url: Option<String>,
pub(crate) file: Option<PathBuf>,
pub(crate) dir: Option<PathBuf>,
pub(crate) tag: Option<String>,
pub(crate) branch: Option<String>,
}
impl PartialOverride for RockSourceInternal {
type Err = Infallible;
fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
Ok(Self {
url: override_opt(override_spec.url.as_ref(), self.url.as_ref()),
file: override_opt(override_spec.file.as_ref(), self.file.as_ref()),
dir: override_opt(override_spec.dir.as_ref(), self.dir.as_ref()),
tag: match &override_spec.branch {
None => override_opt(override_spec.tag.as_ref(), self.tag.as_ref()),
_ => None,
},
branch: match &override_spec.tag {
None => override_opt(override_spec.branch.as_ref(), self.branch.as_ref()),
_ => None,
},
})
}
}
#[derive(Error, Debug)]
#[error("missing source")]
pub struct RockSourceMissingSource;
impl PlatformOverridable for RockSourceInternal {
type Err = RockSourceMissingSource;
fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
where
T: PlatformOverridable,
{
Err(RockSourceMissingSource)
}
}
fn override_opt<T: Clone>(override_opt: Option<&T>, base: Option<&T>) -> Option<T> {
override_opt.or(base).cloned()
}
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum SourceUrl {
File(PathBuf),
Url(Url),
Git(RemoteGitUrl),
}
#[derive(Error, Debug)]
#[error("failed to parse source url: {0}")]
pub enum SourceUrlError {
Io(#[from] io::Error),
Git(#[from] RemoteGitUrlParseError),
Url(#[source] <Url as FromStr>::Err),
#[error("lux does not support rockspecs with CVS sources.")]
CVS,
#[error("lux does not support rockspecs with mercurial sources.")]
Mercurial,
#[error("lux does not support rockspecs with SSCM sources.")]
SSCM,
#[error("lux does not support rockspecs with SVN sources.")]
SVN,
#[error("unsupported source URL prefix: '{0}+' in URL {1}")]
UnsupportedPrefix(String, String),
#[error("unsupported source URL: {0}")]
Unsupported(String),
}
impl FromStr for SourceUrl {
type Err = SourceUrlError;
fn from_str(str: &str) -> Result<Self, Self::Err> {
match str.split_once("+") {
Some(("git" | "gitrec", url)) => Ok(Self::Git(url.parse()?)),
Some((prefix, _)) => Err(SourceUrlError::UnsupportedPrefix(
prefix.to_string(),
str.to_string(),
)),
None => match str {
s if s.starts_with("file://") => {
let path_buf: PathBuf = s.trim_start_matches("file://").into();
let path = fs::canonicalize(&path_buf)?;
Ok(Self::File(path))
}
s if s.starts_with("git://") => {
Ok(Self::Git(s.replacen("git", "https", 1).parse()?))
}
s if s.ends_with(".git") => Ok(Self::Git(s.parse()?)),
s if starts_with_any(s, ["https://", "http://", "ftp://"].into()) => {
Ok(Self::Url(s.parse().map_err(SourceUrlError::Url)?))
}
s if s.starts_with("cvs://") => Err(SourceUrlError::CVS),
s if starts_with_any(
s,
["hg://", "hg+http://", "hg+https://", "hg+ssh://"].into(),
) =>
{
Err(SourceUrlError::Mercurial)
}
s if s.starts_with("sscm://") => Err(SourceUrlError::SSCM),
s if s.starts_with("svn://") => Err(SourceUrlError::SVN),
s => Err(SourceUrlError::Unsupported(s.to_string())),
},
}
}
}
impl<'de> Deserialize<'de> for SourceUrl {
fn deserialize<D>(deserializer: D) -> Result<SourceUrl, D::Error>
where
D: Deserializer<'de>,
{
SourceUrl::from_str(&String::deserialize(deserializer)?).map_err(de::Error::custom)
}
}
fn starts_with_any(str: &str, prefixes: Vec<&str>) -> bool {
prefixes.iter().any(|&prefix| str.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use assert_fs::TempDir;
use super::*;
fn eval_lua_global<T: serde::de::DeserializeOwned>(code: &str, key: &'static str) -> T {
use ottavino::{Closure, Executor, Fuel, Lua};
use ottavino_util::serde::from_value;
Lua::core()
.try_enter(|ctx| {
let closure = Closure::load(ctx, None, code.as_bytes())?;
let executor = Executor::start(ctx, closure.into(), ());
executor.step(ctx, &mut Fuel::with(i32::MAX))?;
from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
})
.unwrap()
}
#[test]
pub fn rock_source_internal_roundtrip() {
let source = RockSourceInternal {
url: Some("https://github.com/example/repo/archive/v1.0.tar.gz".into()),
file: Some("repo-1.0.tar.gz".into()),
dir: Some("repo-1.0".into()),
tag: Some("v1.0".into()),
branch: None,
};
let lua = source.display_lua().to_string();
let restored: RockSourceInternal = eval_lua_global(&lua, "source");
assert_eq!(source, restored);
}
#[test]
pub fn rock_source_internal_branch_roundtrip() {
let source = RockSourceInternal {
url: Some("git+https://github.com/example/repo.git".into()),
file: None,
dir: None,
tag: None,
branch: Some("main".into()),
};
let lua = source.display_lua().to_string();
let restored: RockSourceInternal = eval_lua_global(&lua, "source");
assert_eq!(source, restored);
}
#[tokio::test]
async fn parse_source_url() {
let dir = TempDir::new().unwrap();
let url: SourceUrl = format!("file://{}", dir.to_string_lossy()).parse().unwrap();
assert_eq!(url, SourceUrl::File(dir.path().to_path_buf()));
let url: SourceUrl = "ftp://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Url { .. }));
let url: SourceUrl = "git://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Git { .. }));
SourceUrl::from_str("git+file:///path/to/repo.git").unwrap_err();
let url: SourceUrl = "git+http://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Git { .. }));
let url: SourceUrl = "git+https://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Git { .. }));
let url: SourceUrl = "git+ssh://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Git { .. }));
let url: SourceUrl = "gitrec+https://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Git { .. }));
let url: SourceUrl = "https://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Url { .. }));
let url: SourceUrl = "http://example.com/foo/bar".parse().unwrap();
assert!(matches!(url, SourceUrl::Url { .. }));
}
}