use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use log::{debug, info};
use regex::Regex;
use url::Url;
use crate::requirements::{Requirements, RequirementsError};
use crate::Archetect;
#[derive(Clone, Debug, PartialOrd, PartialEq)]
pub enum Source {
RemoteGit { url: String, path: PathBuf, gitref: Option<String> },
RemoteHttp { url: String, path: PathBuf },
LocalDirectory { path: PathBuf },
LocalFile { path: PathBuf },
}
#[derive(Debug, thiserror::Error)]
pub enum SourceError {
#[error("Unsupported source: `{0}`")]
SourceUnsupported(String),
#[error("Failed to find a default 'develop', 'main', or 'master' branch.")]
NoDefaultBranch,
#[error("Source not found: `{0}`")]
SourceNotFound(String),
#[error("Invalid Source Path: `{0}`")]
SourceInvalidPath(String),
#[error("Invalid Source Encoding: `{0}`")]
SourceInvalidEncoding(String),
#[error("Remote Source Error: `{0}`")]
RemoteSourceError(String),
#[error("Remote Source is not cached, and Archetect was run in offline mode: `{0}`")]
OfflineAndNotCached(String),
#[error("Source IO Error: `{0}`")]
IoError(std::io::Error),
#[error("Requirements Error in `{path}`: {cause}")]
RequirementsError { path: String, cause: RequirementsError },
}
impl From<std::io::Error> for SourceError {
fn from(error: std::io::Error) -> SourceError {
SourceError::IoError(error)
}
}
lazy_static! {
static ref SSH_GIT_PATTERN: Regex = Regex::new(r"\S+@(\S+):(.*)").unwrap();
static ref CACHED_PATHS: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
}
impl Source {
pub fn detect(archetect: &Archetect, path: &str, relative_to: Option<Source>) -> Result<Source, SourceError> {
let source = path;
let git_cache = archetect.layout().git_cache_dir();
let urlparts: Vec<&str> = path.split('#').collect();
if let Some(captures) = SSH_GIT_PATTERN.captures(&urlparts[0]) {
let cache_path = git_cache
.clone()
.join(get_cache_key(format!("{}/{}", &captures[1], &captures[2])));
let gitref = if urlparts.len() > 1 { Some(urlparts[1].to_owned()) } else { None };
if let Err(error) = cache_git_repo(urlparts[0], &gitref, &cache_path, archetect
.offline()) {
return Err(error);
}
verify_requirements(archetect, source, &cache_path)?;
return Ok(Source::RemoteGit {
url: path.to_owned(),
path: cache_path,
gitref,
});
};
if let Ok(url) = Url::parse(&path) {
if path.contains(".git") && url.has_host() {
let cache_path =
git_cache
.clone()
.join(get_cache_key(format!("{}/{}", url.host_str().unwrap(), url.path())));
let gitref = url.fragment().map_or(None, |r| Some(r.to_owned()));
if let Err(error) = cache_git_repo(urlparts[0], &gitref, &cache_path, archetect.offline()) {
return Err(error);
}
verify_requirements(archetect, source, &cache_path)?;
return Ok(Source::RemoteGit {
url: path.to_owned(),
path: cache_path,
gitref,
});
}
if let Ok(local_path) = url.to_file_path() {
return if local_path.exists() {
verify_requirements(archetect, source, &local_path)?;
Ok(Source::LocalDirectory { path: local_path })
} else {
Err(SourceError::SourceNotFound(local_path.display().to_string()))
};
}
}
if let Ok(path) = shellexpand::full(&path) {
let local_path = PathBuf::from(path.as_ref());
if local_path.is_relative() {
if let Some(parent) = relative_to {
let local_path = parent.local_path().clone().join(local_path);
if local_path.exists() && local_path.is_dir() {
verify_requirements(archetect, source, &local_path)?;
return Ok(Source::LocalDirectory { path: local_path });
} else {
return Err(SourceError::SourceNotFound(local_path.display().to_string()));
}
}
}
if local_path.exists() {
if local_path.is_dir() {
verify_requirements(archetect, source, &local_path)?;
return Ok(Source::LocalDirectory { path: local_path });
} else {
return Ok(Source::LocalFile { path: local_path });
}
} else {
return Err(SourceError::SourceNotFound(local_path.display().to_string()));
}
} else {
return Err(SourceError::SourceInvalidPath(path.to_string()));
}
}
pub fn directory(&self) -> &Path {
match self {
Source::RemoteGit { url: _, path, gitref: _ } => path.as_path(),
Source::RemoteHttp { url: _, path } => path.as_path(),
Source::LocalDirectory { path } => path.as_path(),
Source::LocalFile { path } => path.parent().unwrap_or(path),
}
}
pub fn local_path(&self) -> &Path {
match self {
Source::RemoteGit { url: _, path, gitref: _ } => path.as_path(),
Source::RemoteHttp { url: _, path } => path.as_path(),
Source::LocalDirectory { path } => path.as_path(),
Source::LocalFile { path } => path.as_path(),
}
}
pub fn source(&self) -> &str {
match self {
Source::RemoteGit { url, path: _, gitref: _ } => url,
Source::RemoteHttp { url, path: _ } => url,
Source::LocalDirectory { path } => path.to_str().unwrap(),
Source::LocalFile { path } => path.to_str().unwrap(),
}
}
}
fn get_cache_hash<S: AsRef<[u8]>>(input: S) -> u64 {
let result = farmhash::fingerprint64(input.as_ref());
result
}
fn get_cache_key<S: AsRef<[u8]>>(input: S) -> String {
format!("{}", get_cache_hash(input))
}
fn verify_requirements(archetect: &Archetect, source: &str, path: &Path) -> Result<(), SourceError> {
match Requirements::load(&path) {
Ok(results) => {
if let Some(requirements) = results {
if let Err(error) = requirements.verify(archetect) {
return Err(SourceError::RequirementsError {
path: source.to_owned(),
cause: error,
});
}
}
}
Err(error) => {
return Err(SourceError::RequirementsError {
path: path.display().to_string(),
cause: error,
});
}
}
Ok(())
}
fn cache_git_repo(url: &str, gitref: &Option<String>, cache_destination: &Path, offline: bool) -> Result<(),
SourceError> {
if !cache_destination.exists() {
if !offline && CACHED_PATHS.lock().unwrap().insert(url.to_owned()) {
info!("Cloning {}", url);
debug!("Cloning to {}", cache_destination.to_str().unwrap());
handle_git(Command::new("git").args(&["clone", &url, cache_destination.to_str().unwrap()]))?;
} else {
return Err(SourceError::OfflineAndNotCached(url.to_owned()));
}
} else {
if !offline && CACHED_PATHS.lock().unwrap().insert(url.to_owned()) {
info!("Fetching {}", url);
handle_git(Command::new("git").current_dir(&cache_destination).args(&["fetch"]))?;
}
}
let gitref = if let Some(gitref) = gitref {
gitref.to_owned()
} else {
find_default_branch(&cache_destination.to_str().unwrap())?
};
let gitref_spec = if is_branch(&cache_destination.to_str().unwrap(), &gitref) {
format!("origin/{}", &gitref)
} else {
gitref
};
debug!("Checking out {}", gitref_spec);
handle_git(Command::new("git").current_dir(&cache_destination).args(&["checkout", &gitref_spec]))?;
Ok(())
}
fn is_branch(path: &str, gitref: &str) -> bool {
match handle_git(Command::new("git").current_dir(path)
.arg("show-ref")
.arg("-q")
.arg("--verify")
.arg(format!("refs/remotes/origin/{}", gitref))) {
Ok(_) => true,
Err(_) => false,
}
}
fn find_default_branch(path: &str) -> Result<String, SourceError> {
for candidate in &["develop", "main", "master"] {
if is_branch(path, candidate) {
return Ok((*candidate).to_owned());
}
}
Err(SourceError::NoDefaultBranch)
}
fn handle_git(command: &mut Command) -> Result<(), SourceError> {
if cfg!(target_os = "windows") {
command.stdin(Stdio::inherit());
command.stderr(Stdio::inherit());
}
match command.output() {
Ok(output) => match output.status.code() {
Some(0) => Ok(()),
Some(error_code) => Err(SourceError::RemoteSourceError(format!(
"Error Code: {}\n{}",
error_code,
String::from_utf8(output.stderr)
.unwrap_or("Error reading error code from failed git command".to_owned())
))),
None => Err(SourceError::RemoteSourceError("Git interrupted by signal".to_owned())),
},
Err(err) => Err(SourceError::IoError(err)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_hash() {
println!(
"{}",
get_cache_hash("https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT")
);
println!(
"{}",
get_cache_hash("https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT")
);
println!("{}", get_cache_hash("f"));
println!("{}", get_cache_hash("1"));
}
#[test]
fn test_http_source() {
let archetect = Archetect::build().unwrap();
let source = Source::detect(
&archetect,
"https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT",
None,
);
println!("{:?}", source);
}
}