rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
mod dummy;
mod jinja2;
mod rjango_backend;

use minijinja::{Environment, ErrorKind, Value};

use super::defaultfilters::register_default_filters;

pub use dummy::DummyBackend;
pub use jinja2::Jinja2;
pub use rjango_backend::RjangoTemplates;

pub trait TemplateBackend: Send + Sync {
    fn from_string(&self, template_string: &str) -> Result<Template, TemplateError>;
    fn get_template(&self, template_name: &str) -> Result<Template, TemplateError>;
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
    pub name: String,
    pub source: String,
}

impl Template {
    #[must_use]
    pub fn new(name: impl Into<String>, source: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            source: source.into(),
        }
    }

    pub fn render(&self, context: &Value) -> Result<String, TemplateError> {
        let mut env = Environment::new();
        register_default_filters(&mut env);
        env.add_template_owned(self.name.clone(), self.source.clone())
            .map_err(map_minijinja_error)?;
        env.get_template(&self.name)
            .map_err(map_minijinja_error)?
            .render(context.clone())
            .map_err(map_minijinja_error)
    }
}

#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum TemplateError {
    #[error("template not found: {0}")]
    NotFound(String),
    #[error("template syntax error: {0}")]
    SyntaxError(String),
    #[error("render error: {0}")]
    RenderError(String),
}

pub(crate) fn validate_template_source(name: &str, source: &str) -> Result<(), TemplateError> {
    let mut env = Environment::new();
    register_default_filters(&mut env);
    env.add_template_owned(name.to_string(), source.to_string())
        .map(|_| ())
        .map_err(map_minijinja_error)
}

pub(crate) fn map_minijinja_error(error: minijinja::Error) -> TemplateError {
    match error.kind() {
        ErrorKind::TemplateNotFound => TemplateError::NotFound(error.to_string()),
        ErrorKind::SyntaxError => TemplateError::SyntaxError(error.to_string()),
        _ => TemplateError::RenderError(error.to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::{DummyBackend, Jinja2, RjangoTemplates, Template, TemplateBackend, TemplateError};
    use minijinja::{Value, context};
    use std::{
        fs,
        path::{Path, PathBuf},
        time::{SystemTime, UNIX_EPOCH},
    };

    struct TestDir {
        path: PathBuf,
    }

    impl TestDir {
        fn new(prefix: &str) -> Self {
            let nanos = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("system time should be after unix epoch")
                .as_nanos();
            let path = std::env::temp_dir().join(format!(
                "rjango-template-backends-{prefix}-{}-{nanos}",
                std::process::id()
            ));
            fs::create_dir_all(&path).expect("test directory should be created");
            Self { path }
        }

        fn path(&self) -> &Path {
            &self.path
        }

        fn write_file(&self, relative_path: &str, contents: &str) {
            let path = self.path.join(relative_path);
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent).expect("parent directory should be created");
            }
            fs::write(path, contents).expect("test file should be written");
        }
    }

    impl Drop for TestDir {
        fn drop(&mut self) {
            match fs::remove_dir_all(&self.path) {
                Ok(()) | Err(_) => {}
            }
        }
    }

    fn render_with_backend(
        backend: &dyn TemplateBackend,
        source: &str,
        context: &Value,
    ) -> Result<String, TemplateError> {
        backend.from_string(source)?.render(context)
    }

    #[test]
    fn template_construction_preserves_fields() {
        let template = Template::new("hello.html", "Hello {{ name }}!");

        assert_eq!(template.name, "hello.html");
        assert_eq!(template.source, "Hello {{ name }}!");
    }

    #[test]
    fn template_render_renders_context_values() {
        let template = Template::new("hello.html", "Hello {{ name }}!");

        let rendered = template
            .render(&context!(name => "World"))
            .expect("template should render");

        assert_eq!(rendered, "Hello World!");
    }

    #[test]
    fn template_render_registers_default_filters() {
        let template = Template::new("filters.html", "{{ name|upper }} {{ title|slugify }}");

        let rendered = template
            .render(&context!(name => "rjango", title => "Hello Rust World"))
            .expect("template should render with default filters");

        assert_eq!(rendered, "RJANGO hello-rust-world");
    }

    #[test]
    fn template_render_reports_syntax_errors() {
        let template = Template::new("broken.html", "{% if user %}");

        let error = template
            .render(&Value::from(()))
            .expect_err("invalid template syntax should error");

        assert!(matches!(error, TemplateError::SyntaxError(message) if !message.is_empty()));
    }

    #[test]
    fn template_render_reports_missing_includes_as_not_found() {
        let template = Template::new("runtime.html", "{% include \"missing.html\" %}");

        let error = template
            .render(&Value::from(()))
            .expect_err("missing include should error");

        assert!(
            matches!(error, TemplateError::NotFound(message) if message.contains("missing.html"))
        );
    }

    #[test]
    fn template_render_division_by_zero_matches_minijinja_behavior() {
        let template = Template::new("runtime.html", "{{ 1 / 0 }}");

        let rendered = template
            .render(&Value::from(()))
            .expect("MiniJinja currently renders floating infinity for division by zero");

        assert_eq!(rendered, "inf");
    }

    #[test]
    fn template_error_display_messages_are_human_readable() {
        assert_eq!(
            TemplateError::NotFound("missing.html".to_string()).to_string(),
            "template not found: missing.html"
        );
        assert_eq!(
            TemplateError::SyntaxError("bad syntax".to_string()).to_string(),
            "template syntax error: bad syntax"
        );
        assert_eq!(
            TemplateError::RenderError("boom".to_string()).to_string(),
            "render error: boom"
        );
    }

    #[test]
    fn rjango_backend_from_string_returns_renderable_template() {
        let backend = RjangoTemplates::default();

        let rendered = backend
            .from_string("Hello {{ name }}!")
            .expect("template should compile")
            .render(&context!(name => "World"))
            .expect("template should render");

        assert_eq!(rendered, "Hello World!");
    }

    #[test]
    fn template_backend_trait_supports_rjango_backend() {
        let backend = RjangoTemplates::default();

        let rendered =
            render_with_backend(&backend, "{{ name|upper }}", &context!(name => "rjango"))
                .expect("trait object should render through backend");

        assert_eq!(rendered, "RJANGO");
    }

    #[test]
    fn rjango_backend_get_template_loads_from_template_dirs() {
        let dir = TestDir::new("rjango-dir");
        dir.write_file("hello.html", "Hello {{ name }}!");
        let backend = RjangoTemplates::new(vec![dir.path().to_path_buf()], false);

        let rendered = backend
            .get_template("hello.html")
            .expect("template should load")
            .render(&context!(name => "Filesystem"))
            .expect("loaded template should render");

        assert_eq!(rendered, "Hello Filesystem!");
    }

    #[test]
    fn rjango_backend_app_dirs_searches_templates_subdirectory() {
        let dir = TestDir::new("rjango-app-dirs");
        dir.write_file("templates/page.html", "Page for {{ name }}");
        let backend = RjangoTemplates::new(vec![dir.path().to_path_buf()], true);

        let rendered = backend
            .get_template("page.html")
            .expect("template should load from templates subdirectory")
            .render(&context!(name => "apps"))
            .expect("loaded template should render");

        assert_eq!(rendered, "Page for apps");
    }

    #[test]
    fn rjango_backend_reports_missing_templates() {
        let backend = RjangoTemplates::default();

        let error = backend
            .get_template("missing.html")
            .expect_err("missing template should error");

        assert!(matches!(error, TemplateError::NotFound(name) if name == "missing.html"));
    }

    #[test]
    fn jinja2_backend_app_dirs_searches_jinja2_subdirectory() {
        let dir = TestDir::new("jinja2-app-dirs");
        dir.write_file("jinja2/page.html", "Jinja2 says hi to {{ name }}");
        let backend = Jinja2::new(vec![dir.path().to_path_buf()], true);

        let rendered = backend
            .get_template("page.html")
            .expect("template should load from jinja2 subdirectory")
            .render(&context!(name => "World"))
            .expect("loaded template should render");

        assert_eq!(rendered, "Jinja2 says hi to World");
    }

    #[test]
    fn dummy_backend_returns_empty_templates() {
        let backend = DummyBackend::default();

        let template = backend
            .get_template("ignored.html")
            .expect("dummy backend should return empty template");

        assert_eq!(template.name, "ignored.html");
        assert!(template.source.is_empty());
        assert_eq!(
            template
                .render(&Value::from(()))
                .expect("empty template should render"),
            ""
        );
    }

    #[test]
    fn dummy_backend_returns_configured_errors() {
        let backend = DummyBackend::with_error(TemplateError::NotFound("dummy.html".to_string()));

        let error = backend
            .from_string("Hello {{ name }}")
            .expect_err("dummy backend should return configured error");

        assert!(matches!(error, TemplateError::NotFound(name) if name == "dummy.html"));
    }
}