1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::sync::Mutex;
5
6use log::{debug, info};
7use regex::Regex;
8use url::Url;
9
10use crate::requirements::{Requirements, RequirementsError};
11use crate::Archetect;
12
13#[derive(Clone, Debug, PartialOrd, PartialEq)]
14pub enum Source {
15 RemoteGit { url: String, path: PathBuf, gitref: Option<String> },
16 RemoteHttp { url: String, path: PathBuf },
17 LocalDirectory { path: PathBuf },
18 LocalFile { path: PathBuf },
19}
20
21#[derive(Debug, thiserror::Error)]
22pub enum SourceError {
23 #[error("Unsupported source: `{0}`")]
24 SourceUnsupported(String),
25 #[error("Failed to find a default 'develop', 'main', or 'master' branch.")]
26 NoDefaultBranch,
27 #[error("Source not found: `{0}`")]
28 SourceNotFound(String),
29 #[error("Invalid Source Path: `{0}`")]
30 SourceInvalidPath(String),
31 #[error("Invalid Source Encoding: `{0}`")]
32 SourceInvalidEncoding(String),
33 #[error("Remote Source Error: `{0}`")]
34 RemoteSourceError(String),
35 #[error("Remote Source is not cached, and Archetect was run in offline mode: `{0}`")]
36 OfflineAndNotCached(String),
37 #[error("Source IO Error: `{0}`")]
38 IoError(std::io::Error),
39 #[error("Requirements Error in `{path}`: {cause}")]
40 RequirementsError { path: String, cause: RequirementsError },
41}
42
43impl From<std::io::Error> for SourceError {
44 fn from(error: std::io::Error) -> SourceError {
45 SourceError::IoError(error)
46 }
47}
48
49lazy_static! {
50 static ref SSH_GIT_PATTERN: Regex = Regex::new(r"\S+@(\S+):(.*)").unwrap();
51 static ref CACHED_PATHS: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
52}
53
54impl Source {
55 pub fn detect(archetect: &Archetect, path: &str, relative_to: Option<Source>) -> Result<Source, SourceError> {
56 let source = path;
57 let git_cache = archetect.layout().git_cache_dir();
58
59 let urlparts: Vec<&str> = path.split('#').collect();
60 if let Some(captures) = SSH_GIT_PATTERN.captures(&urlparts[0]) {
61
62 let cache_path = git_cache
63 .clone()
64 .join(get_cache_key(format!("{}/{}", &captures[1], &captures[2])));
65
66 let gitref = if urlparts.len() > 1 { Some(urlparts[1].to_owned()) } else { None };
67 if let Err(error) = cache_git_repo(urlparts[0], &gitref, &cache_path, archetect
68 .offline()) {
69 return Err(error);
70 }
71 verify_requirements(archetect, source, &cache_path)?;
72 return Ok(Source::RemoteGit {
73 url: path.to_owned(),
74 path: cache_path,
75 gitref,
76 });
77 };
78
79 if let Ok(url) = Url::parse(&path) {
80 if path.contains(".git") && url.has_host() {
81 let cache_path =
82 git_cache
83 .clone()
84 .join(get_cache_key(format!("{}/{}", url.host_str().unwrap(), url.path())));
85 let gitref = url.fragment().map_or(None, |r| Some(r.to_owned()));
86 if let Err(error) = cache_git_repo(urlparts[0], &gitref, &cache_path, archetect.offline()) {
87 return Err(error);
88 }
89 verify_requirements(archetect, source, &cache_path)?;
90 return Ok(Source::RemoteGit {
91 url: path.to_owned(),
92 path: cache_path,
93 gitref,
94 });
95 }
96
97 if let Ok(local_path) = url.to_file_path() {
98 return if local_path.exists() {
99 verify_requirements(archetect, source, &local_path)?;
100 Ok(Source::LocalDirectory { path: local_path })
101 } else {
102 Err(SourceError::SourceNotFound(local_path.display().to_string()))
103 };
104 }
105 }
106
107 if let Ok(path) = shellexpand::full(&path) {
108 let local_path = PathBuf::from(path.as_ref());
109 if local_path.is_relative() {
110 if let Some(parent) = relative_to {
111 let local_path = parent.local_path().clone().join(local_path);
112 if local_path.exists() && local_path.is_dir() {
113 verify_requirements(archetect, source, &local_path)?;
114 return Ok(Source::LocalDirectory { path: local_path });
115 } else {
116 return Err(SourceError::SourceNotFound(local_path.display().to_string()));
117 }
118 }
119 }
120 if local_path.exists() {
121 if local_path.is_dir() {
122 verify_requirements(archetect, source, &local_path)?;
123 return Ok(Source::LocalDirectory { path: local_path });
124 } else {
125 return Ok(Source::LocalFile { path: local_path });
126 }
127 } else {
128 return Err(SourceError::SourceNotFound(local_path.display().to_string()));
129 }
130 } else {
131 return Err(SourceError::SourceInvalidPath(path.to_string()));
132 }
133 }
134
135 pub fn directory(&self) -> &Path {
136 match self {
137 Source::RemoteGit { url: _, path, gitref: _ } => path.as_path(),
138 Source::RemoteHttp { url: _, path } => path.as_path(),
139 Source::LocalDirectory { path } => path.as_path(),
140 Source::LocalFile { path } => path.parent().unwrap_or(path),
141 }
142 }
143
144 pub fn local_path(&self) -> &Path {
145 match self {
146 Source::RemoteGit { url: _, path, gitref: _ } => path.as_path(),
147 Source::RemoteHttp { url: _, path } => path.as_path(),
148 Source::LocalDirectory { path } => path.as_path(),
149 Source::LocalFile { path } => path.as_path(),
150 }
151 }
152
153 pub fn source(&self) -> &str {
154 match self {
155 Source::RemoteGit { url, path: _, gitref: _ } => url,
156 Source::RemoteHttp { url, path: _ } => url,
157 Source::LocalDirectory { path } => path.to_str().unwrap(),
158 Source::LocalFile { path } => path.to_str().unwrap(),
159 }
160 }
161}
162
163fn get_cache_hash<S: AsRef<[u8]>>(input: S) -> u64 {
164 let result = farmhash::fingerprint64(input.as_ref());
165 result
166}
167
168fn get_cache_key<S: AsRef<[u8]>>(input: S) -> String {
169 format!("{}", get_cache_hash(input))
170}
171
172fn verify_requirements(archetect: &Archetect, source: &str, path: &Path) -> Result<(), SourceError> {
173 match Requirements::load(&path) {
174 Ok(results) => {
175 if let Some(requirements) = results {
176 if let Err(error) = requirements.verify(archetect) {
177 return Err(SourceError::RequirementsError {
178 path: source.to_owned(),
179 cause: error,
180 });
181 }
182 }
183 }
184 Err(error) => {
185 return Err(SourceError::RequirementsError {
186 path: path.display().to_string(),
187 cause: error,
188 });
189 }
190 }
191 Ok(())
192}
193
194fn cache_git_repo(url: &str, gitref: &Option<String>, cache_destination: &Path, offline: bool) -> Result<(),
195 SourceError> {
196 if !cache_destination.exists() {
197 if !offline && CACHED_PATHS.lock().unwrap().insert(url.to_owned()) {
198 info!("Cloning {}", url);
199 debug!("Cloning to {}", cache_destination.to_str().unwrap());
200 handle_git(Command::new("git").args(&["clone", &url, cache_destination.to_str().unwrap()]))?;
201 } else {
202 return Err(SourceError::OfflineAndNotCached(url.to_owned()));
203 }
204 } else {
205 if !offline && CACHED_PATHS.lock().unwrap().insert(url.to_owned()) {
206 info!("Fetching {}", url);
207 handle_git(Command::new("git").current_dir(&cache_destination).args(&["fetch"]))?;
208 }
209 }
210
211 let gitref = if let Some(gitref) = gitref {
212 gitref.to_owned()
213 } else {
214 find_default_branch(&cache_destination.to_str().unwrap())?
215 };
216
217 let gitref_spec = if is_branch(&cache_destination.to_str().unwrap(), &gitref) {
218 format!("origin/{}", &gitref)
219 } else {
220 gitref
221 };
222
223 debug!("Checking out {}", gitref_spec);
224 handle_git(Command::new("git").current_dir(&cache_destination).args(&["checkout", &gitref_spec]))?;
225
226 Ok(())
227}
228
229fn is_branch(path: &str, gitref: &str) -> bool {
230 match handle_git(Command::new("git").current_dir(path)
231 .arg("show-ref")
232 .arg("-q")
233 .arg("--verify")
234 .arg(format!("refs/remotes/origin/{}", gitref))) {
235 Ok(_) => true,
236 Err(_) => false,
237 }
238}
239
240fn find_default_branch(path: &str) -> Result<String, SourceError> {
241 for candidate in &["develop", "main", "master"] {
242 if is_branch(path, candidate) {
243 return Ok((*candidate).to_owned());
244 }
245 }
246 Err(SourceError::NoDefaultBranch)
247}
248
249fn handle_git(command: &mut Command) -> Result<(), SourceError> {
250 if cfg!(target_os = "windows") {
251 command.stdin(Stdio::inherit());
252 command.stderr(Stdio::inherit());
253 }
254 match command.output() {
255 Ok(output) => match output.status.code() {
256 Some(0) => Ok(()),
257 Some(error_code) => Err(SourceError::RemoteSourceError(format!(
258 "Error Code: {}\n{}",
259 error_code,
260 String::from_utf8(output.stderr)
261 .unwrap_or("Error reading error code from failed git command".to_owned())
262 ))),
263 None => Err(SourceError::RemoteSourceError("Git interrupted by signal".to_owned())),
264 },
265 Err(err) => Err(SourceError::IoError(err)),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn test_cache_hash() {
275 println!(
276 "{}",
277 get_cache_hash("https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT")
278 );
279 println!(
280 "{}",
281 get_cache_hash("https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT")
282 );
283 println!("{}", get_cache_hash("f"));
284 println!("{}", get_cache_hash("1"));
285 }
286
287 #[test]
288 fn test_http_source() {
289 let archetect = Archetect::build().unwrap();
290 let source = Source::detect(
291 &archetect,
292 "https://raw.githubusercontent.com/archetect/archetect/master/LICENSE-MIT-MIT",
293 None,
294 );
295 println!("{:?}", source);
296 }
297
298 }