Skip to main content

ninja/scripting/
templater.rs

1use crate::common::types::FieldValue;
2use anyhow::Result;
3use log::{debug, error, info};
4use std::{collections::HashMap, env, error::Error, fmt::Display, path::PathBuf};
5use tera::{Context, Error as TeraError, ErrorKind, Function, Tera, Value};
6use tokio::{fs, sync::RwLock};
7
8#[derive(Debug)]
9pub enum TemplateError {
10    NotFound(String),
11    InvalidConfig(String),
12    Internal(String),
13    PathNotFound(PathBuf),
14}
15
16impl Display for TemplateError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            TemplateError::InvalidConfig(msg) => write!(f, "Invalid configuration: {}", msg),
20            TemplateError::Internal(msg) => write!(f, "Internal template error: {}", msg),
21            TemplateError::NotFound(tmpl) => write!(f, "Template '{}' not found", tmpl),
22            TemplateError::PathNotFound(path) => {
23                write!(f, "Path '{}' not found or inaccessible", path.display())
24            }
25        }
26    }
27}
28
29impl Error for TemplateError {}
30
31pub struct Templater {
32    context: HashMap<String, FieldValue>,
33    root: PathBuf,
34    tera: RwLock<Tera>,
35}
36
37impl Templater {
38    pub fn new(
39        mut context: HashMap<String, FieldValue>,
40        root_path: PathBuf,
41    ) -> Result<Self, TemplateError> {
42        debug!("Templater::new: root = {}", root_path.display());
43
44        // Inject default keys
45        context
46            .entry("platform".to_string())
47            .or_insert_with(|| FieldValue::String(env::consts::OS.to_string()));
48        context
49            .entry("root".to_string())
50            .or_insert_with(|| FieldValue::String(root_path.display().to_string()));
51
52        context
53            .entry("arch".into())
54            .or_insert_with(|| FieldValue::String(env::consts::ARCH.to_string()));
55
56        let ninja_root = root_path
57            .parent()
58            .and_then(|p| p.parent())
59            .ok_or_else(|| {
60                TemplateError::InvalidConfig(format!(
61                    "Failed to resolve ninja_root from root_path: {}",
62                    root_path.display()
63                ))
64            })?
65            .to_path_buf();
66
67        context
68            .entry("ninja_root".into())
69            .or_insert_with(|| FieldValue::String(ninja_root.display().to_string()));
70
71        let username = whoami::username()
72            .map_err(|e| TemplateError::Internal(format!("Failed to get username: {}", e)))?;
73
74        context
75            .entry("user".into())
76            .or_insert_with(|| FieldValue::String(username));
77
78        debug!(
79            "Templater::new: context size after injection = {}",
80            context.len()
81        );
82
83        let pattern = root_path.join(".ninja").join("**/*.tmpl");
84        let pattern_str = pattern.to_string_lossy();
85
86        info!(
87            "Templater::new: compiling Tera templates with pattern '{}'",
88            pattern_str
89        );
90
91        let mut tera = Tera::new(&pattern_str).map_err(|e| {
92            error!(
93                "Templater::new: failed to compile templates (pattern '{}'): {}",
94                pattern_str, e
95            );
96            TemplateError::Internal(format!("Failed to compile templates: {}", e))
97        })?;
98
99        tera.register_function("path", PathFunction);
100
101        info!(
102            "Templater::new: initialized successfully (root = {}, pattern = {})",
103            root_path.display(),
104            pattern_str
105        );
106
107        Ok(Self {
108            context,
109            root: root_path,
110            tera: RwLock::new(tera),
111        })
112    }
113
114    fn to_tera_context(&self) -> Context {
115        let mut ctx = Context::new();
116        for (key, value) in &self.context {
117            ctx.insert(key, value);
118        }
119        ctx
120    }
121
122    async fn render_with_diagnostics(
123        &self,
124        name: &str,
125        template: &str,
126    ) -> Result<String, TemplateError> {
127        debug!(
128            "Templater::render_with_diagnostics: rendering template '{}' (len = {})",
129            name,
130            template.len()
131        );
132
133        let ctx = self.to_tera_context();
134        let mut tera_guard = self.tera.write().await;
135
136        match tera_guard.render_str(template, &ctx) {
137            Ok(output) => {
138                debug!(
139                    "Templater::render_with_diagnostics: rendered '{}' (output len = {})",
140                    name,
141                    output.len()
142                );
143                Ok(output)
144            }
145            Err(err) => {
146                error!(
147                    "Templater::render_with_diagnostics: error rendering '{}': {}",
148                    name, err
149                );
150                Err(Self::diagnose_tera_error(err, name))
151            }
152        }
153    }
154
155    fn diagnose_tera_error(err: TeraError, name: &str) -> TemplateError {
156        // Base message
157        let mut msg = format!("Error rendering template '{}': {}", name, err);
158
159        match &err.kind {
160            ErrorKind::TemplateNotFound(tmpl) => {
161                error!(
162                    "Templater::diagnose_tera_error: template '{}' not found (referenced as '{}')",
163                    tmpl, name
164                );
165                return TemplateError::NotFound(tmpl.clone());
166            }
167            other => {
168                msg.push_str(&format!(" ({:?})", other));
169            }
170        }
171
172        // Collect cause chain for context
173        let mut source_opt = err.source();
174        let mut depth = 0usize;
175        while let Some(source) = source_opt {
176            depth += 1;
177            msg.push_str(&format!(" | cause[{depth}]: {}", source));
178            source_opt = source.source();
179        }
180
181        error!("Templater::diagnose_tera_error: {}", msg);
182        TemplateError::Internal(msg)
183    }
184
185    pub async fn parse_template(&self, template: &str) -> Result<String, TemplateError> {
186        debug!(
187            "Templater::parse_template: inline template (len = {})",
188            template.len()
189        );
190        let result = self.render_with_diagnostics("<inline>", template).await;
191
192        if let Err(err) = &result {
193            error!("Templater::parse_template: error: {}", err);
194        }
195
196        result
197    }
198
199    pub async fn generate_config(&self, config_path: PathBuf) -> Result<(), TemplateError> {
200        debug!(
201            "Templater::generate_config: target config path = {}",
202            config_path.display()
203        );
204
205        let template_path = self.root.join(".ninja").join("config.tmpl");
206        debug!(
207            "Templater::generate_config: template path = {}",
208            template_path.display()
209        );
210
211        let template_content = fs::read_to_string(&template_path).await.map_err(|e| {
212            error!(
213                "Templater::generate_config: failed to read template '{}': {}",
214                template_path.display(),
215                e
216            );
217            TemplateError::PathNotFound(template_path.clone())
218        })?;
219
220        debug!(
221            "Templater::generate_config: read template (len = {}) from '{}'",
222            template_content.len(),
223            template_path.display()
224        );
225
226        let rendered = self
227            .render_with_diagnostics("config.tmpl", &template_content)
228            .await?;
229
230        debug!(
231            "Templater::generate_config: writing rendered config (len = {}) to '{}'",
232            rendered.len(),
233            config_path.display()
234        );
235
236        fs::write(&config_path, rendered).await.map_err(|e| {
237            error!(
238                "Templater::generate_config: failed to write config '{}': {}",
239                config_path.display(),
240                e
241            );
242            TemplateError::PathNotFound(config_path.clone())
243        })?;
244
245        info!(
246            "Templater::generate_config: config generated at '{}'",
247            config_path.display()
248        );
249        Ok(())
250    }
251}
252
253#[allow(dead_code)]
254struct PathFunction;
255
256#[allow(dead_code)]
257fn path(args: &HashMap<String, Value>) -> Result<tera::Value> {
258    let root = args.get("root").and_then(|v| v.as_str()).unwrap_or("");
259
260    let os_sep = std::path::MAIN_SEPARATOR.to_string();
261
262    let sep = args.get("sep").and_then(|v| v.as_str()).unwrap_or(&os_sep);
263
264    let path = args
265        .get("path")
266        .ok_or_else(|| anyhow::anyhow!("path argument required"))?
267        .as_str()
268        .ok_or_else(|| anyhow::anyhow!("path must be a string"))?;
269
270    let mut parts: Vec<&str> = Vec::new();
271
272    if !root.is_empty() {
273        #[cfg(windows)]
274        {
275            root.split('\\')
276                .filter(|s| !s.is_empty())
277                .for_each(|s| parts.push(s));
278        }
279
280        #[cfg(not(windows))]
281        {
282            root.split('/')
283                .filter(|s| !s.is_empty())
284                .for_each(|s| parts.push(s));
285        }
286    }
287
288    parts.extend(path.split('/').filter(|s| !s.is_empty()));
289    Ok(tera::Value::String(parts.join(sep)))
290}
291
292impl Function for PathFunction {
293    fn call(&self, args: &HashMap<String, Value>) -> tera::Result<tera::Value> {
294        path(args).map_err(|e| TeraError::msg(e))
295    }
296}