codebase/
archetype.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io::{ErrorKind, Write};
4use std::path::Path;
5use std::process::{Command, ExitStatus, Stdio};
6
7use regex::Regex;
8use serde::Deserialize;
9use walkdir::WalkDir;
10
11use crate::git_util;
12
13const ARCHETYPE_FILE: &str = "archetype.json";
14const ARCHETYPES_DIR: &str = "archetypes";
15const ARCHETYPE_REPO: &str = "https://github.com/codebase-rs/archetypes.git";
16const TEMPLATE_DIR: &str = "template";
17const CONFIG_REGEX: &str = r"\{\{[A-Z-_]+}}";
18
19/// Represent an archetype
20#[derive(Deserialize, Clone)]
21pub struct Archetype {
22    /// Archetype description
23    pub description: String,
24    /// Archetype author
25    pub author: String,
26    /// The executable to use to generate the project
27    pub executable: Option<String>,
28    /// The arguments passed to the executable
29    pub arguments: Option<Vec<String>>,
30    /// The archetype default configuration values
31    pub default_config: BTreeMap<String, String>,
32}
33
34/// Find an archetype using his name
35///
36/// # Arguments
37/// * `name` - the name of the archetype
38/// * `config_dir` - path to the .codebase-config directory
39pub fn find<P: AsRef<Path>>(name: &str, config_dir: P) -> anyhow::Result<Option<Archetype>> {
40    let archetypes = archetypes(config_dir)?;
41    Ok(archetypes
42        .iter()
43        .find(|(archetype_name, _)| *archetype_name == &name.to_string())
44        .map(|(_, archetype)| archetype.clone()))
45}
46
47/// Get existing archetypes
48///
49/// # Arguments
50/// * `config_dir` - path to the .codebase-config directory
51pub fn archetypes<P: AsRef<Path>>(config_dir: P) -> anyhow::Result<BTreeMap<String, Archetype>> {
52    // Clone archetypes / update it
53    let archetypes_dir = config_dir.as_ref().join(ARCHETYPES_DIR);
54
55    if !archetypes_dir.exists() {
56        git2::Repository::clone(ARCHETYPE_REPO, &archetypes_dir)?;
57    } else {
58        let repo = git2::Repository::open(&archetypes_dir)?;
59        git_util::pull(&repo, "origin", "master")?;
60    }
61
62    let mut archetypes: BTreeMap<String, Archetype> = BTreeMap::new();
63
64    for entry in WalkDir::new(&archetypes_dir)
65        .into_iter()
66        .filter_map(Result::ok)
67        .filter(|e| e.file_name().to_str().unwrap() == ARCHETYPE_FILE)
68    {
69        // Extract 'local' manifest directory
70        let archetypes_dir = archetypes_dir.to_str().unwrap();
71        let local_path: String = entry
72            .path()
73            .to_str()
74            .unwrap()
75            .replace(&format!("{}/", archetypes_dir), "")
76            .replace(&format!("/{}", ARCHETYPE_FILE), "");
77        let archetype_id = local_path;
78
79        // Read manifest file
80        let json = fs::read_to_string(&entry.path()).unwrap();
81        let json: Archetype = serde_json::from_str(&json).unwrap();
82
83        archetypes.insert(archetype_id, json);
84    }
85
86    Ok(archetypes)
87}
88
89/// Execute given archetype with given configuration
90///
91/// # Arguments
92/// * `archetype_id` - the archetype ID
93/// * `config` - the config to use
94/// * `directory` - the directory where to executable should run
95/// * `config_dir` - path to the .codebase-config directory
96pub fn execute<A: AsRef<Path>, B: AsRef<Path>>(
97    archetype_id: &str,
98    config: &BTreeMap<String, String>,
99    directory: A,
100    config_dir: B,
101) -> anyhow::Result<()> {
102    let archetype = find(&archetype_id, &config_dir)?.ok_or_else(|| {
103        anyhow::anyhow!(format!("No archetype with name `{}` found", archetype_id))
104    })?;
105
106    // Wrapper archetype
107    if archetype.executable.is_some() {
108        execute_wrapper(&archetype, config, directory).map(|_| ())
109    } else {
110        execute_template(archetype_id, &archetype, config, directory, &config_dir)
111    }
112}
113
114/// Execute wrapper archetype
115///
116/// # Arguments
117/// * `archetype` - the archetype to use
118/// * `config` - the config to use
119/// * `directory` - the directory where to executable should run
120fn execute_wrapper<P: AsRef<Path>>(
121    archetype: &Archetype,
122    config: &BTreeMap<String, String>,
123    directory: P,
124) -> anyhow::Result<ExitStatus> {
125    let arguments = build_arguments(archetype, config)?;
126
127    Command::new(&archetype.executable.as_ref().unwrap())
128        .args(arguments)
129        .current_dir(directory)
130        .stdout(Stdio::null())
131        .stderr(Stdio::null())
132        .status()
133        .map_err(|e| {
134            if let ErrorKind::NotFound = e.kind() {
135                anyhow::anyhow!(format!(
136                    "Missing executable '{}'",
137                    archetype.executable.as_ref().unwrap()
138                ))
139            } else {
140                anyhow::Error::new(e)
141            }
142        })
143}
144
145/// Execute template archetype
146///
147/// # Arguments
148/// * `archetype_id` - the archetype ID
149/// * `archetype` - the linked archetype
150/// * `config` - the config to use
151/// * `directory` - the directory where to executable should run
152/// * `config_dir` - path to the .codebase-config directory
153fn execute_template<A: AsRef<Path>, B: AsRef<Path>>(
154    archetype_id: &str,
155    archetype: &Archetype,
156    config: &BTreeMap<String, String>,
157    directory: A,
158    config_dir: B,
159) -> anyhow::Result<()> {
160    let project_name = config.get("NAME").ok_or_else(|| anyhow::anyhow!(""))?;
161    let project_directory = directory.as_ref().join(project_name);
162
163    let template_dir = config_dir
164        .as_ref()
165        .join(ARCHETYPES_DIR)
166        .join(archetype_id)
167        .join(TEMPLATE_DIR);
168
169    if !template_dir.exists() {
170        return Err(anyhow::anyhow!(format!(
171            "Template directory {} does not exist",
172            template_dir.display()
173        )));
174    }
175
176    // Then copy each template files
177    for entry in WalkDir::new(&template_dir)
178        .into_iter()
179        .filter_map(Result::ok)
180    {
181        let path: &Path = entry.path();
182        let local_path = path.strip_prefix(&template_dir)?;
183        let project_path = project_directory.join(local_path);
184
185        // If it's a directory, simply create it
186        if entry.file_type().is_dir() {
187            fs::create_dir_all(&project_path)?;
188            continue;
189        }
190
191        // If it's a file let's open it first, and extrapolate variables if any
192        let content = fs::read_to_string(path)?;
193        let content = process_template(&content, &archetype, &config)?;
194
195        // ... then write back result to file
196        let mut file = fs::File::create(project_path)?;
197        file.write_all(content.as_bytes())?;
198    }
199
200    Ok(())
201}
202
203/// Build the arguments using archetype & provided configuration
204///
205/// # Arguments
206/// * `archetype` - the archetype to build arguments for
207/// * `config` - the archetype configuration
208fn build_arguments(
209    archetype: &Archetype,
210    config: &BTreeMap<String, String>,
211) -> anyhow::Result<Vec<String>> {
212    let mut arguments = Vec::new();
213
214    // Short-circuit if no argument are available
215    if archetype.arguments.is_none() {
216        return Ok(arguments);
217    }
218
219    let config_regex = Regex::new(CONFIG_REGEX)?;
220    for arg in archetype.arguments.as_ref().unwrap() {
221        if config_regex.is_match(&arg) {
222            // 'Config' argument, search for value in provided config
223            let config_name = arg.replace("{{", "").replace("}}", "");
224            let config_value = config
225                .get(&config_name)
226                .or_else(|| archetype.default_config.get(&config_name))
227                .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;
228            arguments.push(config_value.clone());
229        } else {
230            // 'Simple' argument, simply append it
231            arguments.push(arg.clone());
232        }
233    }
234
235    Ok(arguments)
236}
237
238/// Process/Interpolate given template
239/// rendering any config into values
240///
241/// # Arguments
242/// * `content` - template content
243/// * `archetype` - the linked archetype
244/// * `config` - linked config
245fn process_template(
246    content: &str,
247    archetype: &Archetype,
248    config: &BTreeMap<String, String>,
249) -> anyhow::Result<String> {
250    let mut result = content.to_string();
251    let config_regex = Regex::new(CONFIG_REGEX)?;
252
253    for variable in config_regex.find_iter(&content) {
254        let config_name = variable.as_str().replace("{{", "").replace("}}", "");
255        let config_value = config
256            .get(&config_name)
257            .or_else(|| archetype.default_config.get(&config_name))
258            .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;
259
260        result = result.replace(variable.as_str(), config_value);
261    }
262
263    Ok(result)
264}
265
266#[cfg(test)]
267mod tests {
268    use std::collections::BTreeMap;
269
270    use crate::archetype::{build_arguments, process_template, Archetype};
271
272    #[test]
273    fn test_build_arguments() {
274        let mut default_config: BTreeMap<String, String> = BTreeMap::new();
275        default_config.insert("YEAR".to_string(), "2018".to_string());
276
277        let archetype = Archetype {
278            description: "".to_string(),
279            author: "Aloïs Micard <alois@micard.lu>".to_string(),
280            executable: Some("cargo".to_string()),
281            arguments: Some(vec![
282                "new".to_string(),
283                "{{NAME}}".to_string(),
284                "--bin".to_string(),
285                "--edition".to_string(),
286                "{{YEAR}}".to_string(),
287            ]),
288            default_config,
289        };
290
291        let mut config = BTreeMap::new();
292        let result = build_arguments(&archetype, &config);
293        assert!(result.is_err()); // Missing NAME argument
294
295        config.insert("NAME".to_string(), "test".to_string());
296        let result = build_arguments(&archetype, &config);
297        assert!(result.is_ok());
298        assert_eq!(
299            result.unwrap(),
300            vec!["new", "test", "--bin", "--edition", "2018"]
301        );
302    }
303
304    #[test]
305    fn test_process_template() {
306        let mut default_config: BTreeMap<String, String> = BTreeMap::new();
307        default_config.insert("FAV_DRINK".to_string(), "beer".to_string());
308
309        let archetype = Archetype {
310            description: "".to_string(),
311            author: "".to_string(),
312            executable: None,
313            arguments: None,
314            default_config,
315        };
316
317        let content = "Hello, my name is {{NAME}} and I like drinking {{FAV_DRINK}}!";
318
319        let mut config: BTreeMap<String, String> = BTreeMap::new();
320        let result = process_template(&content, &archetype, &config);
321        assert!(result.is_err());
322
323        config.insert("NAME".to_string(), "Aloïs Micard".to_string());
324        let result = process_template(&content, &archetype, &config);
325        assert!(result.is_ok());
326        assert_eq!(
327            result.unwrap(),
328            "Hello, my name is Aloïs Micard and I like drinking beer!"
329        )
330    }
331}