archetect_core/
source.rs

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    //    use super::*;
299    //    use matches::assert_matches;
300
301    //    #[test]
302    //    fn test_detect_short_git_url() {
303    //        // TODO: Fix this test.
304    //        assert_matches!(
305    //            Location::detect("git@github.com:jimmiebfulton/archetect.git", ),
306    //            Ok(Location::RemoteGit { url: _, path: _ })
307    //        );
308    //    }
309    //
310    //    #[test]
311    //    fn test_detect_http_git_url() {
312    //        // TODO: Fix this test.
313    //        assert_matches!(
314    //            Location::detect("https://github.com/jimmiebfulton/archetect.git"),
315    //            Ok(Location::RemoteGit { url: _, path: _ })
316    //        );
317    //    }
318    //
319    //    #[test]
320    //    fn test_detect_local_directory() {
321    //        assert_eq!(
322    //            Location::detect(".", false),
323    //            Ok(Location::LocalDirectory {
324    //                path: PathBuf::from(".")
325    //            })
326    //        );
327    //
328    //        assert_matches!(
329    //            Location::detect("~"),
330    //            Ok(Location::LocalDirectory { path: _ })
331    //        );
332    //
333    //        assert_eq!(
334    //            Location::detect("notfound", false),
335    //            Err(LocationError::LocationNotFound)
336    //        );
337    //    }
338    //
339    //    #[test]
340    //    fn test_file_url() {
341    //        assert_eq!(
342    //            Location::detect("file://localhost/home", false),
343    //            Ok(Location::LocalDirectory {
344    //                path: PathBuf::from("/home")
345    //            }),
346    //        );
347    //
348    //        assert_eq!(
349    //            Location::detect("file:///home", false),
350    //            Ok(Location::LocalDirectory {
351    //                path: PathBuf::from("/home")
352    //            }),
353    //        );
354    //
355    //        assert_eq!(
356    //            Location::detect("file://localhost/nope", false),
357    //            Err(LocationError::LocationNotFound),
358    //        );
359    //
360    //        assert_eq!(
361    //            Location::detect("file://nope/home", false),
362    //            Err(LocationError::LocationUnsupported),
363    //        );
364    //    }
365    //
366    //    #[test]
367    //    fn test_short_git_pattern() {
368    //        let captures = SSH_GIT_PATTERN
369    //            .captures("git@github.com:jimmiebfulton/archetect.git")
370    //            .unwrap();
371    //        assert_eq!(&captures[1], "github.com");
372    //        assert_eq!(&captures[2], "jimmiebfulton/archetect.git");
373    //    }
374}