subplot 0.2.0

tools for specifying, documenting, and implementing automated acceptance tests for systems and software
Documentation
use crate::{resource, Document, SubplotError, TemplateSpec};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};

use base64::encode;

use serde::Serialize;
use tera::{Context, Tera, Value};

/// Return the requested template specification.
pub fn template_spec(doc: &Document) -> Result<TemplateSpec, SubplotError> {
    let template = doc
        .meta()
        .template_name()
        .ok_or(SubplotError::MissingTemplate)?;

    let mut filename = PathBuf::from(template);
    filename.push("template");
    filename.push("template.yaml");
    TemplateSpec::from_file(&filename)
}

/// Generate a test program from a document, using a template spec.
pub fn generate_test_program(
    doc: &mut Document,
    spec: &TemplateSpec,
    filename: &Path,
) -> Result<(), SubplotError> {
    let context = context(doc)?;
    let code = tera(&spec)?.render("template", &context).expect("render");
    write(filename, &code)?;
    Ok(())
}

fn context(doc: &mut Document) -> Result<Context, SubplotError> {
    let mut context = Context::new();
    context.insert("scenarios", &doc.matched_scenarios()?);
    context.insert("files", doc.files());

    let funcs_filenames = doc.meta().functions_filenames();
    let mut funcs = vec![];
    for filename in funcs_filenames {
        let content = resource::read_as_string(filename)
            .map_err(|err| SubplotError::FunctionsFileNotFound(filename.into(), err))?;
        funcs.push(Func::new(filename, content));
    }
    context.insert("functions", &funcs);

    Ok(context)
}

fn tera(tmplspec: &TemplateSpec) -> Result<Tera, SubplotError> {
    // Tera insists on a glob, but we want to load a specific template
    // only, so we use a glob that doesn't match anything.
    let mut tera = Tera::new(&"/..IGNORE-THIS../..SUBPLOT-TERA-NOT-EXIST../*").expect("new");
    tera.register_filter("base64", base64);
    tera.register_filter("nameslug", nameslug);
    tera.register_filter("commentsafe", commentsafe);
    let dirname = tmplspec.template_filename().parent().unwrap();
    for helper in tmplspec.helpers() {
        let helper_path = dirname.join(helper);
        let helper_content = resource::read_as_string(helper_path)?;
        let helper_name = helper.display().to_string();
        tera.add_raw_template(&helper_name, &helper_content)
            .map_err(|err| SubplotError::TemplateError(helper_name.to_string(), err))?;
    }
    let template = resource::read_as_string(tmplspec.template_filename())?;
    tera.add_raw_template("template", &template)
        .map_err(|err| {
            SubplotError::TemplateError(tmplspec.template_filename().display().to_string(), err)
        })?;
    Ok(tera)
}

fn write(filename: &Path, content: &str) -> Result<(), SubplotError> {
    let mut f: File = File::create(filename)?;
    f.write_all(&content.as_bytes())?;
    Ok(())
}

fn base64(v: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
    match v {
        Value::String(s) => Ok(Value::String(encode(s))),
        _ => Err(tera::Error::msg(
            "can only base64 encode strings".to_string(),
        )),
    }
}

fn nameslug(name: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
    match name {
        Value::String(s) => {
            let newname = s
                .chars()
                .map(|c| match c {
                    'a'..='z' => c,
                    'A'..='Z' => c.to_ascii_lowercase(),
                    _ => '_',
                })
                .collect();
            Ok(Value::String(newname))
        }
        _ => Err(tera::Error::msg(
            "can only create nameslugs from strings".to_string(),
        )),
    }
}

fn commentsafe(s: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
    match s {
        Value::String(s) => {
            let cleaned = s
                .chars()
                .map(|c| match c {
                    '\n' | '\r' => ' ',
                    _ => c,
                })
                .collect();
            Ok(Value::String(cleaned))
        }
        _ => Err(tera::Error::msg(
            "can only make clean comments from strings".to_string(),
        )),
    }
}

#[derive(Debug, Serialize)]
pub struct Func {
    pub source: PathBuf,
    pub code: String,
}

impl Func {
    pub fn new(source: &Path, code: String) -> Func {
        Func {
            source: source.to_path_buf(),
            code,
        }
    }
}

#[cfg(test)]
mod test {
    use std::collections::HashMap;
    use tera::Value;
    #[test]
    fn verify_commentsafe_filter() {
        static GOOD_CASES: &[(&str, &str)] = &[
            ("", ""),                                         // Empty
            ("hello world", "hello world"),                   // basic strings pass through
            ("Capitalised Words", "Capitalised Words"),       // capitals are OK
            ("multiple\nlines\rblah", "multiple lines blah"), // line breaks are made into spaces
        ];
        for (input, output) in GOOD_CASES.iter().copied() {
            let input = Value::from(input);
            let output = Value::from(output);
            let empty = HashMap::new();
            assert_eq!(super::commentsafe(&input, &empty).ok(), Some(output));
        }
    }

    #[test]
    fn verify_name_slugification() {
        static GOOD_CASES: &[(&str, &str)] = &[
            ("foobar", "foobar"),        // Simple words pass through
            ("FooBar", "foobar"),        // Capital letters are lowercased
            ("Motörhead", "mot_rhead"), // Non-ascii characters are changed for underscores
            ("foo bar", "foo_bar"),      // As is whitespace etc.
        ];
        for (input, output) in GOOD_CASES.iter().copied() {
            let input = Value::from(input);
            let output = Value::from(output);
            let empty = HashMap::new();
            assert_eq!(super::nameslug(&input, &empty).ok(), Some(output));
        }
    }
}