automaat_processor_git_clone/
lib.rs

1//! An [Automaat] processor to clone a Git repository.
2//!
3//! Using this crate in your Automaat workflow allows you to clone an external
4//! repository into the [`Context`] workspace.
5//!
6//! Plaintext username/password authentication is supported for private
7//! repositories.
8//!
9//! [`Context`]: https://docs.rs/automaat-core/0.1/automaat_core/struct.Context.html
10//! [Automaat]: https://docs.rs/automaat-core
11//!
12//! # Examples
13//!
14//! Clone the Automaat repository into the workspace of the created context, and
15//! assert that the repository is in the correct location.
16//!
17//! Since this repository is open to the public, no credentials are required.
18//!
19//! The workspace is a temporary directory created on your file system. See the
20//! [`Context`] documentation for more details.
21//!
22//! ```rust
23//! # fn main() -> Result<(), Box<std::error::Error>> {
24//! use automaat_core::{Context, Processor};
25//! use automaat_processor_git_clone::GitClone;
26//! use url::Url;
27//!
28//! let context = Context::new()?;
29//! let repo_url = Url::parse("https://github.com/blendle/automaat")?;
30//!
31//! let processor = GitClone {
32//!   url: repo_url,
33//!   username: None,
34//!   password: None,
35//!   path: Some("automaat-repo".to_owned())
36//! };
37//!
38//! processor.run(&context)?;
39//!
40//! assert!(context.workspace_path().join("automaat-repo/README.md").exists());
41//! #     Ok(())
42//! # }
43//! ```
44//!
45//! # Package Features
46//!
47//! * `juniper` – creates a set of objects to be used in GraphQL-based
48//!   requests/responses.
49#![deny(
50    clippy::all,
51    clippy::cargo,
52    clippy::nursery,
53    clippy::pedantic,
54    deprecated_in_future,
55    future_incompatible,
56    missing_docs,
57    nonstandard_style,
58    rust_2018_idioms,
59    rustdoc,
60    warnings,
61    unused_results,
62    unused_qualifications,
63    unused_lifetimes,
64    unused_import_braces,
65    unsafe_code,
66    unreachable_pub,
67    trivial_casts,
68    trivial_numeric_casts,
69    missing_debug_implementations,
70    missing_copy_implementations
71)]
72#![warn(variant_size_differences)]
73#![allow(clippy::multiple_crate_versions, missing_doc_code_examples)]
74#![doc(html_root_url = "https://docs.rs/automaat-processor-git-clone/0.1.0")]
75
76use automaat_core::{Context, Processor};
77use git2::{build::RepoBuilder, Cred, FetchOptions, RemoteCallbacks};
78use serde::{Deserialize, Serialize};
79use std::{error, fmt, path};
80use url::Url;
81
82/// The processor configuration.
83#[cfg_attr(feature = "juniper", derive(juniper::GraphQLObject))]
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct GitClone {
86    /// The URL of the remote to fetch the repository from.
87    #[serde(with = "url_serde")]
88    pub url: Url,
89
90    /// The optional username used to authenticate with the remote.
91    pub username: Option<String>,
92
93    /// The optional password used to authenticate with the remote.
94    pub password: Option<String>,
95
96    /// An optional path inside the workspace to clone the repository to. If no
97    /// path is given, the root of the workspace is used. If the path does not
98    /// exist, it will be created.
99    pub path: Option<String>,
100}
101
102/// The GraphQL [Input Object] used to initialize the processor via an API.
103///
104/// [`GitClone`] implements `From<Input>`, so you can directly initialize the
105/// processor using this type.
106///
107/// _requires the `juniper` package feature to be enabled_
108#[cfg(feature = "juniper")]
109#[cfg_attr(feature = "juniper", derive(juniper::GraphQLInputObject))]
110#[cfg_attr(feature = "juniper", graphql(name = "GitCloneInput"))]
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
112pub struct Input {
113    #[serde(with = "url_serde")]
114    url: Url,
115
116    username: Option<String>,
117    password: Option<String>,
118    path: Option<String>,
119}
120
121#[cfg(feature = "juniper")]
122impl From<Input> for GitClone {
123    fn from(input: Input) -> Self {
124        Self {
125            username: input.username,
126            password: input.password,
127            url: input.url,
128            path: input.path,
129        }
130    }
131}
132
133impl<'a> Processor<'a> for GitClone {
134    const NAME: &'static str = "Git Clone";
135
136    type Error = Error;
137    type Output = String;
138
139    /// Validate the `GitClone` configuration.
140    ///
141    /// # Errors
142    ///
143    /// This method returns an error under the following circumstances:
144    ///
145    /// * If a `path` option is provided that contains anything other than a
146    ///   simple relative path such as `my/path`. Anything such as `../`, or
147    ///   `/etc` is not allowed. The returned error is of type [`Error::Path`].
148    ///
149    /// In a future update, this will also validate remote connectivity.
150    fn validate(&self) -> Result<(), Self::Error> {
151        if let Some(path) = &self.path {
152            let path = path::Path::new(path);
153
154            path.components().try_for_each(|c| match c {
155                path::Component::Normal(_) => Ok(()),
156                _ => Err(Error::Path),
157            })?;
158        };
159
160        Ok(())
161    }
162
163    /// Clone the repository as defined by the provided configuration.
164    ///
165    /// The repository will be cloned in the [`automaat_core::Context`]
166    /// workspace, optionally in a child `path`.
167    ///
168    /// # Output
169    ///
170    /// `None` is returned if the processor runs successfully.
171    ///
172    /// # Errors
173    ///
174    /// Any errors during cloning will return an [`Error::Git`] result value.
175    fn run(&self, context: &Context) -> Result<Option<Self::Output>, Self::Error> {
176        let mut callbacks = RemoteCallbacks::new();
177        let mut fetch_options = FetchOptions::new();
178        let workspace = context.workspace_path();
179        let path = self
180            .path
181            .as_ref()
182            .map_or_else(|| workspace.into(), |path| workspace.join(path));
183
184        if let (Some(u), Some(p)) = (&self.username, &self.password) {
185            let _ = callbacks.credentials(move |_, _, _| Cred::userpass_plaintext(u, p));
186            let _ = fetch_options.remote_callbacks(callbacks);
187        };
188
189        RepoBuilder::new()
190            .fetch_options(fetch_options)
191            .clone(self.url.as_str(), &path)
192            .map(|_| None)
193            .map_err(Into::into)
194    }
195}
196
197/// Represents all the ways that [`GitClone`] can fail.
198///
199/// This type is not intended to be exhaustively matched, and new variants may
200/// be added in the future without a major version bump.
201#[derive(Debug)]
202pub enum Error {
203    /// The provided [`GitClone::path`] configuration is invalid.
204    Path,
205
206    /// An error occurred while cloning the Git repository.
207    Git(git2::Error),
208
209    #[doc(hidden)]
210    __Unknown, // Match against _ instead, more variants may be added in the future.
211}
212
213impl fmt::Display for Error {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        match *self {
216            Error::Path => write!(f, "Path error: invalid path location"),
217            Error::Git(ref err) => write!(f, "Git error: {}", err),
218            Error::__Unknown => unreachable!(),
219        }
220    }
221}
222
223impl error::Error for Error {
224    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
225        match *self {
226            Error::Path => None,
227            Error::Git(ref err) => Some(err),
228            Error::__Unknown => unreachable!(),
229        }
230    }
231}
232
233impl From<git2::Error> for Error {
234    fn from(err: git2::Error) -> Self {
235        Error::Git(err)
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn processor_stub() -> GitClone {
244        GitClone {
245            username: None,
246            password: None,
247            url: Url::parse("http://127.0.0.1").unwrap(),
248            path: None,
249        }
250    }
251
252    mod validate {
253        use super::*;
254
255        #[test]
256        fn no_path() {
257            let mut processor = processor_stub();
258            processor.path = None;
259
260            processor.validate().unwrap()
261        }
262
263        #[test]
264        fn relative_path() {
265            let mut processor = processor_stub();
266            processor.path = Some("hello/world".to_owned());
267
268            processor.validate().unwrap()
269        }
270
271        #[test]
272        #[should_panic]
273        fn prefix_path() {
274            let mut processor = processor_stub();
275            processor.path = Some("../parent".to_owned());
276
277            processor.validate().unwrap()
278        }
279
280        #[test]
281        #[should_panic]
282        fn absolute_path() {
283            let mut processor = processor_stub();
284            processor.path = Some("/etc".to_owned());
285
286            processor.validate().unwrap()
287        }
288    }
289
290    #[test]
291    fn test_readme_deps() {
292        version_sync::assert_markdown_deps_updated!("README.md");
293    }
294
295    #[test]
296    fn test_html_root_url() {
297        version_sync::assert_html_root_url_updated!("src/lib.rs");
298    }
299}