codebase 0.11.0

Manage your codebase like a boss!
Documentation
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!"
        )
    }
}