ninja-core 1.14.0

A powerful, cross-platform package manager and runtime for managing tools and plugins (shurikens)
Documentation
use crate::common::types::FieldValue;
use anyhow::Result;
use log::{debug, error, info};
use std::{collections::HashMap, env, error::Error, fmt::Display, path::PathBuf};
use tera::{Context, Error as TeraError, ErrorKind, Function, Tera, Value};
use tokio::{fs, sync::RwLock};

#[derive(Debug)]
pub enum TemplateError {
    NotFound(String),
    InvalidConfig(String),
    Internal(String),
    PathNotFound(PathBuf),
}

impl Display for TemplateError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            TemplateError::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
            TemplateError::Internal(msg) => write!(f, "Internal template error: {}", msg),
            TemplateError::NotFound(tmpl) => write!(f, "Template '{}' not found", tmpl),
            TemplateError::PathNotFound(path) => {
                write!(f, "Path '{}' not found or inaccessible", path.display())
            }
        }
    }
}

impl Error for TemplateError {}

pub struct Templater {
    context: HashMap<String, FieldValue>,
    root: PathBuf,
    tera: RwLock<Tera>,
}

impl Templater {
    pub fn new(
        mut context: HashMap<String, FieldValue>,
        root_path: PathBuf,
    ) -> Result<Self, TemplateError> {
        debug!("Templater::new: root = {}", root_path.display());

        // Inject default keys
        context
            .entry("platform".to_string())
            .or_insert_with(|| FieldValue::String(env::consts::OS.to_string()));
        context
            .entry("root".to_string())
            .or_insert_with(|| FieldValue::String(root_path.display().to_string()));

        context
            .entry("arch".into())
            .or_insert_with(|| FieldValue::String(env::consts::ARCH.to_string()));

        let ninja_root = root_path
            .parent()
            .and_then(|p| p.parent())
            .ok_or_else(|| {
                TemplateError::InvalidConfig(format!(
                    "Failed to resolve ninja_root from root_path: {}",
                    root_path.display()
                ))
            })?
            .to_path_buf();

        context
            .entry("ninja_root".into())
            .or_insert_with(|| FieldValue::String(ninja_root.display().to_string()));

        let username = whoami::username()
            .map_err(|e| TemplateError::Internal(format!("Failed to get username: {}", e)))?;

        context
            .entry("user".into())
            .or_insert_with(|| FieldValue::String(username));

        debug!(
            "Templater::new: context size after injection = {}",
            context.len()
        );

        let pattern = root_path.join(".ninja").join("**/*.tmpl");
        let pattern_str = pattern.to_string_lossy();

        info!(
            "Templater::new: compiling Tera templates with pattern '{}'",
            pattern_str
        );

        let mut tera = Tera::new(&pattern_str).map_err(|e| {
            error!(
                "Templater::new: failed to compile templates (pattern '{}'): {}",
                pattern_str, e
            );
            TemplateError::Internal(format!("Failed to compile templates: {}", e))
        })?;

        tera.register_function("path", PathFunction);

        info!(
            "Templater::new: initialized successfully (root = {}, pattern = {})",
            root_path.display(),
            pattern_str
        );

        Ok(Self {
            context,
            root: root_path,
            tera: RwLock::new(tera),
        })
    }

    fn to_tera_context(&self) -> Context {
        let mut ctx = Context::new();
        for (key, value) in &self.context {
            ctx.insert(key, value);
        }
        ctx
    }

    async fn render_with_diagnostics(
        &self,
        name: &str,
        template: &str,
    ) -> Result<String, TemplateError> {
        debug!(
            "Templater::render_with_diagnostics: rendering template '{}' (len = {})",
            name,
            template.len()
        );

        let ctx = self.to_tera_context();
        let mut tera_guard = self.tera.write().await;

        match tera_guard.render_str(template, &ctx) {
            Ok(output) => {
                debug!(
                    "Templater::render_with_diagnostics: rendered '{}' (output len = {})",
                    name,
                    output.len()
                );
                Ok(output)
            }
            Err(err) => {
                error!(
                    "Templater::render_with_diagnostics: error rendering '{}': {}",
                    name, err
                );
                Err(Self::diagnose_tera_error(err, name))
            }
        }
    }

    fn diagnose_tera_error(err: TeraError, name: &str) -> TemplateError {
        // Base message
        let mut msg = format!("Error rendering template '{}': {}", name, err);

        match &err.kind {
            ErrorKind::TemplateNotFound(tmpl) => {
                error!(
                    "Templater::diagnose_tera_error: template '{}' not found (referenced as '{}')",
                    tmpl, name
                );
                return TemplateError::NotFound(tmpl.clone());
            }
            other => {
                msg.push_str(&format!(" ({:?})", other));
            }
        }

        // Collect cause chain for context
        let mut source_opt = err.source();
        let mut depth = 0usize;
        while let Some(source) = source_opt {
            depth += 1;
            msg.push_str(&format!(" | cause[{depth}]: {}", source));
            source_opt = source.source();
        }

        error!("Templater::diagnose_tera_error: {}", msg);
        TemplateError::Internal(msg)
    }

    pub async fn parse_template(&self, template: &str) -> Result<String, TemplateError> {
        debug!(
            "Templater::parse_template: inline template (len = {})",
            template.len()
        );
        let result = self.render_with_diagnostics("<inline>", template).await;

        if let Err(err) = &result {
            error!("Templater::parse_template: error: {}", err);
        }

        result
    }

    pub async fn generate_config(&self, config_path: PathBuf) -> Result<(), TemplateError> {
        debug!(
            "Templater::generate_config: target config path = {}",
            config_path.display()
        );

        let template_path = self.root.join(".ninja").join("config.tmpl");
        debug!(
            "Templater::generate_config: template path = {}",
            template_path.display()
        );

        let template_content = fs::read_to_string(&template_path).await.map_err(|e| {
            error!(
                "Templater::generate_config: failed to read template '{}': {}",
                template_path.display(),
                e
            );
            TemplateError::PathNotFound(template_path.clone())
        })?;

        debug!(
            "Templater::generate_config: read template (len = {}) from '{}'",
            template_content.len(),
            template_path.display()
        );

        let rendered = self
            .render_with_diagnostics("config.tmpl", &template_content)
            .await?;

        debug!(
            "Templater::generate_config: writing rendered config (len = {}) to '{}'",
            rendered.len(),
            config_path.display()
        );

        fs::write(&config_path, rendered).await.map_err(|e| {
            error!(
                "Templater::generate_config: failed to write config '{}': {}",
                config_path.display(),
                e
            );
            TemplateError::PathNotFound(config_path.clone())
        })?;

        info!(
            "Templater::generate_config: config generated at '{}'",
            config_path.display()
        );
        Ok(())
    }
}

#[allow(dead_code)]
struct PathFunction;

#[allow(dead_code)]
fn path(args: &HashMap<String, Value>) -> Result<tera::Value> {
    let root = args.get("root").and_then(|v| v.as_str()).unwrap_or("");

    let os_sep = std::path::MAIN_SEPARATOR.to_string();

    let sep = args.get("sep").and_then(|v| v.as_str()).unwrap_or(&os_sep);

    let path = args
        .get("path")
        .ok_or_else(|| anyhow::anyhow!("path argument required"))?
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("path must be a string"))?;

    let mut parts: Vec<&str> = Vec::new();

    if !root.is_empty() {
        #[cfg(windows)]
        {
            root.split('\\')
                .filter(|s| !s.is_empty())
                .for_each(|s| parts.push(s));
        }

        #[cfg(not(windows))]
        {
            root.split('/')
                .filter(|s| !s.is_empty())
                .for_each(|s| parts.push(s));
        }
    }

    parts.extend(path.split('/').filter(|s| !s.is_empty()));
    Ok(tera::Value::String(parts.join(sep)))
}

impl Function for PathFunction {
    fn call(&self, args: &HashMap<String, Value>) -> tera::Result<tera::Value> {
        path(args).map_err(|e| TeraError::msg(e))
    }
}