1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
use std::collections::BTreeMap;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::process::{Command, ExitStatus, Stdio};

use regex::Regex;
use serde::Deserialize;
use walkdir::WalkDir;

use crate::git_util;

const ARCHETYPE_FILE: &str = "archetype.json";
const ARCHETYPES_DIR: &str = "archetypes";
const ARCHETYPE_REPO: &str = "https://github.com/codebase-rs/archetypes.git";
const TEMPLATE_DIR: &str = "template";
const CONFIG_REGEX: &str = r"\{\{[A-Z-_]+}}";

/// Represent an archetype
#[derive(Deserialize, Clone)]
pub struct Archetype {
    /// Archetype description
    pub description: String,
    /// Archetype author
    pub author: String,
    /// The executable to use to generate the project
    pub executable: Option<String>,
    /// The arguments passed to the executable
    pub arguments: Option<Vec<String>>,
    /// The archetype default configuration values
    pub default_config: BTreeMap<String, String>,
}

/// Find an archetype using his name
///
/// # Arguments
/// * `name` - the name of the archetype
/// * `config_dir` - path to the .codebase-config directory
pub fn find<P: AsRef<Path>>(name: &str, config_dir: P) -> anyhow::Result<Option<Archetype>> {
    let archetypes = archetypes(config_dir)?;
    Ok(archetypes
        .iter()
        .find(|(archetype_name, _)| *archetype_name == &name.to_string())
        .map(|(_, archetype)| archetype.clone()))
}

/// Get existing archetypes
///
/// # Arguments
/// * `config_dir` - path to the .codebase-config directory
pub fn archetypes<P: AsRef<Path>>(config_dir: P) -> anyhow::Result<BTreeMap<String, Archetype>> {
    // Clone archetypes / update it
    let archetypes_dir = config_dir.as_ref().join(ARCHETYPES_DIR);

    if !archetypes_dir.exists() {
        git2::Repository::clone(ARCHETYPE_REPO, &archetypes_dir)?;
    } else {
        let repo = git2::Repository::open(&archetypes_dir)?;
        git_util::pull(&repo, "origin", "master")?;
    }

    let mut archetypes: BTreeMap<String, Archetype> = BTreeMap::new();

    for entry in WalkDir::new(&archetypes_dir)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_name().to_str().unwrap() == ARCHETYPE_FILE)
    {
        // Extract 'local' manifest directory
        let archetypes_dir = archetypes_dir.to_str().unwrap();
        let local_path: String = entry
            .path()
            .to_str()
            .unwrap()
            .replace(&format!("{}/", archetypes_dir), "")
            .replace(&format!("/{}", ARCHETYPE_FILE), "");
        let archetype_id = local_path;

        // Read manifest file
        let json = fs::read_to_string(&entry.path()).unwrap();
        let json: Archetype = serde_json::from_str(&json).unwrap();

        archetypes.insert(archetype_id, json);
    }

    Ok(archetypes)
}

/// Execute given archetype with given configuration
///
/// # Arguments
/// * `archetype_id` - the archetype ID
/// * `config` - the config to use
/// * `directory` - the directory where to executable should run
/// * `config_dir` - path to the .codebase-config directory
pub fn execute<A: AsRef<Path>, B: AsRef<Path>>(
    archetype_id: &str,
    config: &BTreeMap<String, String>,
    directory: A,
    config_dir: B,
) -> anyhow::Result<()> {
    let archetype = find(&archetype_id, &config_dir)?.ok_or_else(|| {
        anyhow::anyhow!(format!("No archetype with name `{}` found", archetype_id))
    })?;

    // Wrapper archetype
    if archetype.executable.is_some() {
        execute_wrapper(&archetype, config, directory).map(|_| ())
    } else {
        execute_template(archetype_id, &archetype, config, directory, &config_dir)
    }
}

/// Execute wrapper archetype
///
/// # Arguments
/// * `archetype` - the archetype to use
/// * `config` - the config to use
/// * `directory` - the directory where to executable should run
fn execute_wrapper<P: AsRef<Path>>(
    archetype: &Archetype,
    config: &BTreeMap<String, String>,
    directory: P,
) -> anyhow::Result<ExitStatus> {
    let arguments = build_arguments(archetype, config)?;

    Command::new(&archetype.executable.as_ref().unwrap())
        .args(arguments)
        .current_dir(directory)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map_err(|e| {
            if let ErrorKind::NotFound = e.kind() {
                anyhow::anyhow!(format!(
                    "Missing executable '{}'",
                    archetype.executable.as_ref().unwrap()
                ))
            } else {
                anyhow::Error::new(e)
            }
        })
}

/// Execute template archetype
///
/// # Arguments
/// * `archetype_id` - the archetype ID
/// * `archetype` - the linked archetype
/// * `config` - the config to use
/// * `directory` - the directory where to executable should run
/// * `config_dir` - path to the .codebase-config directory
fn execute_template<A: AsRef<Path>, B: AsRef<Path>>(
    archetype_id: &str,
    archetype: &Archetype,
    config: &BTreeMap<String, String>,
    directory: A,
    config_dir: B,
) -> anyhow::Result<()> {
    let project_name = config.get("NAME").ok_or_else(|| anyhow::anyhow!(""))?;
    let project_directory = directory.as_ref().join(project_name);

    let template_dir = config_dir
        .as_ref()
        .join(ARCHETYPES_DIR)
        .join(archetype_id)
        .join(TEMPLATE_DIR);

    if !template_dir.exists() {
        return Err(anyhow::anyhow!(format!(
            "Template directory {} does not exist",
            template_dir.display()
        )));
    }

    // Then copy each template files
    for entry in WalkDir::new(&template_dir)
        .into_iter()
        .filter_map(Result::ok)
    {
        let path: &Path = entry.path();
        let local_path = path.strip_prefix(&template_dir)?;
        let project_path = project_directory.join(local_path);

        // If it's a directory, simply create it
        if entry.file_type().is_dir() {
            fs::create_dir_all(&project_path)?;
            continue;
        }

        // If it's a file let's open it first, and extrapolate variables if any
        let content = fs::read_to_string(path)?;
        let content = process_template(&content, &archetype, &config)?;

        // ... then write back result to file
        let mut file = fs::File::create(project_path)?;
        file.write_all(content.as_bytes())?;
    }

    Ok(())
}

/// Build the arguments using archetype & provided configuration
///
/// # Arguments
/// * `archetype` - the archetype to build arguments for
/// * `config` - the archetype configuration
fn build_arguments(
    archetype: &Archetype,
    config: &BTreeMap<String, String>,
) -> anyhow::Result<Vec<String>> {
    let mut arguments = Vec::new();

    // Short-circuit if no argument are available
    if archetype.arguments.is_none() {
        return Ok(arguments);
    }

    let config_regex = Regex::new(CONFIG_REGEX)?;
    for arg in archetype.arguments.as_ref().unwrap() {
        if config_regex.is_match(&arg) {
            // 'Config' argument, search for value in provided config
            let config_name = arg.replace("{{", "").replace("}}", "");
            let config_value = config
                .get(&config_name)
                .or_else(|| archetype.default_config.get(&config_name))
                .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;
            arguments.push(config_value.clone());
        } else {
            // 'Simple' argument, simply append it
            arguments.push(arg.clone());
        }
    }

    Ok(arguments)
}

/// Process/Interpolate given template
/// rendering any config into values
///
/// # Arguments
/// * `content` - template content
/// * `archetype` - the linked archetype
/// * `config` - linked config
fn process_template(
    content: &str,
    archetype: &Archetype,
    config: &BTreeMap<String, String>,
) -> anyhow::Result<String> {
    let mut result = content.to_string();
    let config_regex = Regex::new(CONFIG_REGEX)?;

    for variable in config_regex.find_iter(&content) {
        let config_name = variable.as_str().replace("{{", "").replace("}}", "");
        let config_value = config
            .get(&config_name)
            .or_else(|| archetype.default_config.get(&config_name))
            .ok_or_else(|| anyhow::anyhow!("Missing archetype config '{}'", config_name))?;

        result = result.replace(variable.as_str(), config_value);
    }

    Ok(result)
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use crate::archetype::{build_arguments, process_template, Archetype};

    #[test]
    fn test_build_arguments() {
        let mut default_config: BTreeMap<String, String> = BTreeMap::new();
        default_config.insert("YEAR".to_string(), "2018".to_string());

        let archetype = Archetype {
            description: "".to_string(),
            author: "Aloïs Micard <alois@micard.lu>".to_string(),
            executable: Some("cargo".to_string()),
            arguments: Some(vec![
                "new".to_string(),
                "{{NAME}}".to_string(),
                "--bin".to_string(),
                "--edition".to_string(),
                "{{YEAR}}".to_string(),
            ]),
            default_config,
        };

        let mut config = BTreeMap::new();
        let result = build_arguments(&archetype, &config);
        assert!(result.is_err()); // Missing NAME argument

        config.insert("NAME".to_string(), "test".to_string());
        let result = build_arguments(&archetype, &config);
        assert!(result.is_ok());
        assert_eq!(
            result.unwrap(),
            vec!["new", "test", "--bin", "--edition", "2018"]
        );
    }

    #[test]
    fn test_process_template() {
        let mut default_config: BTreeMap<String, String> = BTreeMap::new();
        default_config.insert("FAV_DRINK".to_string(), "beer".to_string());

        let archetype = Archetype {
            description: "".to_string(),
            author: "".to_string(),
            executable: None,
            arguments: None,
            default_config,
        };

        let content = "Hello, my name is {{NAME}} and I like drinking {{FAV_DRINK}}!";

        let mut config: BTreeMap<String, String> = BTreeMap::new();
        let result = process_template(&content, &archetype, &config);
        assert!(result.is_err());

        config.insert("NAME".to_string(), "Aloïs Micard".to_string());
        let result = process_template(&content, &archetype, &config);
        assert!(result.is_ok());
        assert_eq!(
            result.unwrap(),
            "Hello, my name is Aloïs Micard and I like drinking beer!"
        )
    }
}