mdvault_core/templates/
engine.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use chrono::Local;
6use thiserror::Error;
7use tracing::debug;
8
9use crate::config::types::ResolvedConfig;
10use crate::vars::datemath::{evaluate_date_expr, is_date_expr, parse_date_expr};
11
12use super::discovery::TemplateInfo;
13use super::repository::LoadedTemplate;
14
15#[derive(Debug, Error)]
16pub enum TemplateRenderError {
17    #[error("invalid regex for template placeholder: {0}")]
18    Regex(String),
19}
20
21pub type RenderContext = HashMap<String, String>;
22
23/// Build a minimal render context with date/time and config variables.
24///
25/// This is useful for resolving template output paths from frontmatter
26/// before the actual output path is known.
27pub fn build_minimal_context(
28    cfg: &ResolvedConfig,
29    template: &TemplateInfo,
30) -> RenderContext {
31    let mut ctx = RenderContext::new();
32
33    // Date/time (basic versions - date math expressions are handled separately)
34    let now = Local::now();
35    ctx.insert("date".into(), now.format("%Y-%m-%d").to_string());
36    ctx.insert("time".into(), now.format("%H:%M").to_string());
37    ctx.insert("datetime".into(), now.to_rfc3339());
38    // Add today/now as aliases
39    ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
40    ctx.insert("now".into(), now.to_rfc3339());
41
42    // From config
43    ctx.insert("vault_root".into(), cfg.vault_root.to_string_lossy().to_string());
44    ctx.insert("templates_dir".into(), cfg.templates_dir.to_string_lossy().to_string());
45    ctx.insert("captures_dir".into(), cfg.captures_dir.to_string_lossy().to_string());
46    ctx.insert("macros_dir".into(), cfg.macros_dir.to_string_lossy().to_string());
47
48    // Template info
49    ctx.insert("template_name".into(), template.logical_name.clone());
50    ctx.insert("template_path".into(), template.path.to_string_lossy().to_string());
51
52    ctx
53}
54
55pub fn build_render_context(
56    cfg: &ResolvedConfig,
57    template: &TemplateInfo,
58    output_path: &Path,
59) -> RenderContext {
60    let mut ctx = build_minimal_context(cfg, template);
61
62    // Output info
63    let output_abs = absolutize(output_path);
64    ctx.insert("output_path".into(), output_abs.to_string_lossy().to_string());
65    if let Some(name) = output_abs.file_name().and_then(|s| s.to_str()) {
66        ctx.insert("output_filename".into(), name.to_string());
67    }
68    if let Some(parent) = output_abs.parent() {
69        ctx.insert("output_dir".into(), parent.to_string_lossy().to_string());
70    }
71
72    ctx
73}
74
75fn absolutize(path: &Path) -> PathBuf {
76    if path.is_absolute() {
77        path.to_path_buf()
78    } else {
79        std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
80    }
81}
82
83pub fn render(
84    template: &LoadedTemplate,
85    ctx: &RenderContext,
86) -> Result<String, TemplateRenderError> {
87    debug!("Rendering template '{}' with vars: {:?}", template.logical_name, ctx.keys());
88    let rendered_body = render_string(&template.body, ctx)?;
89
90    // Check if template has frontmatter to include in output.
91    // We render from the RAW frontmatter text to avoid YAML parsing issues
92    // with template variables like {{title}} being interpreted as YAML mappings.
93    if template.frontmatter.is_some()
94        && let Some(raw_fm) = extract_raw_frontmatter(&template.content)
95    {
96        // Filter out template-specific fields (output, lua, vars)
97        let filtered_fm = filter_template_fields(&raw_fm);
98        if !filtered_fm.trim().is_empty() {
99            // Render variables in the filtered frontmatter text
100            let rendered_fm = render_string(&filtered_fm, ctx)?;
101            return Ok(format!("---\n{}---\n\n{}", rendered_fm, rendered_body));
102        }
103    }
104
105    Ok(rendered_body)
106}
107
108/// Extract raw frontmatter text from content (without the --- delimiters).
109fn extract_raw_frontmatter(content: &str) -> Option<String> {
110    let trimmed = content.trim_start();
111    if !trimmed.starts_with("---") {
112        return None;
113    }
114
115    let after_first = &trimmed[3..];
116    let after_newline = after_first
117        .strip_prefix('\n')
118        .or_else(|| after_first.strip_prefix("\r\n"))
119        .unwrap_or(after_first);
120
121    // Find closing ---
122    for (i, line) in after_newline.lines().enumerate() {
123        if line.trim() == "---" {
124            let pos: usize = after_newline.lines().take(i).map(|l| l.len() + 1).sum();
125            return Some(after_newline[..pos].to_string());
126        }
127    }
128    None
129}
130
131/// Filter out template-specific fields (output, lua, vars) from raw frontmatter.
132/// These fields are used by the template system and should not appear in output.
133fn filter_template_fields(raw_fm: &str) -> String {
134    let template_fields = ["output:", "lua:", "vars:"];
135    let mut result = Vec::new();
136    let mut skip_until_next_field = false;
137
138    for line in raw_fm.lines() {
139        // Check if this line starts a template-specific field
140        let trimmed = line.trim_start();
141        let starts_field = template_fields.iter().any(|f| trimmed.starts_with(f));
142
143        if starts_field {
144            // Start skipping this field and any continuation lines
145            skip_until_next_field = true;
146            continue;
147        }
148
149        // Check if this is a continuation line (indented) or a new field
150        if skip_until_next_field {
151            // If line is indented (starts with whitespace) or empty, it's a continuation
152            if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
153                continue;
154            }
155            // New top-level field, stop skipping
156            skip_until_next_field = false;
157        }
158
159        result.push(line);
160    }
161
162    let mut filtered = result.join("\n");
163    // Ensure trailing newline if original had one
164    if raw_fm.ends_with('\n') && !filtered.ends_with('\n') {
165        filtered.push('\n');
166    }
167    filtered
168}
169
170/// Render a string template with variable substitution.
171///
172/// Supports:
173/// - Simple variables: `{{var_name}}`
174/// - Date math expressions: `{{today + 1d}}`, `{{now - 2h}}`, `{{today | %Y-%m-%d}}`
175/// - Filters: `{{var_name | filter}}` (currently supports: slugify)
176pub fn render_string(
177    template: &str,
178    ctx: &RenderContext,
179) -> Result<String, TemplateRenderError> {
180    // Match both simple vars and date math expressions
181    // Captures everything between {{ and }} that looks like a valid expression
182    let re = Regex::new(r"\{\{([^{}]+)\}\}")
183        .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
184
185    let result = re.replace_all(template, |caps: &regex::Captures<'_>| {
186        let expr = caps[1].trim();
187
188        // First, check if it's a date math expression
189        if is_date_expr(expr)
190            && let Ok(parsed) = parse_date_expr(expr)
191        {
192            return evaluate_date_expr(&parsed);
193        }
194
195        // Check for filter syntax: "var_name | filter"
196        if let Some((var_name, filter)) = parse_filter_expr(expr) {
197            if let Some(value) = ctx.get(var_name) {
198                return apply_filter(value, filter);
199            }
200            debug!("Template variable not found for filter: {}", var_name);
201            // Variable not found, return original
202            return caps[0].to_string();
203        }
204
205        // Otherwise, try simple variable lookup
206        if let Some(val) = ctx.get(expr) {
207            val.clone()
208        } else {
209            debug!("Template variable not found: {}", expr);
210            caps[0].to_string()
211        }
212    });
213
214    Ok(result.into_owned())
215}
216
217/// Parse a filter expression like "var_name | filter_name".
218/// Returns (var_name, filter_name) if valid, None otherwise.
219fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
220    // Don't parse date expressions with format as filters (e.g., "today | %Y-%m-%d")
221    if is_date_expr(expr) {
222        return None;
223    }
224
225    let parts: Vec<&str> = expr.splitn(2, '|').collect();
226    if parts.len() == 2 {
227        let var_name = parts[0].trim();
228        let filter = parts[1].trim();
229        if !var_name.is_empty() && !filter.is_empty() {
230            return Some((var_name, filter));
231        }
232    }
233    None
234}
235
236/// Apply a filter to a value.
237fn apply_filter(value: &str, filter: &str) -> String {
238    match filter {
239        "slugify" => slugify(value),
240        "lowercase" | "lower" => value.to_lowercase(),
241        "uppercase" | "upper" => value.to_uppercase(),
242        "trim" => value.trim().to_string(),
243        _ => value.to_string(), // Unknown filter, return unchanged
244    }
245}
246
247/// Convert a string to a URL-friendly slug.
248///
249/// - Converts to lowercase
250/// - Replaces spaces and underscores with hyphens
251/// - Removes non-alphanumeric characters (except hyphens)
252/// - Collapses multiple hyphens into one
253/// - Trims leading/trailing hyphens
254fn slugify(s: &str) -> String {
255    let mut result = String::with_capacity(s.len());
256
257    for c in s.chars() {
258        if c.is_ascii_alphanumeric() {
259            result.push(c.to_ascii_lowercase());
260        } else if c == ' ' || c == '_' || c == '-' {
261            // Only add hyphen if last char wasn't already a hyphen
262            if !result.ends_with('-') {
263                result.push('-');
264            }
265        }
266        // Other characters are skipped
267    }
268
269    // Trim leading/trailing hyphens
270    result.trim_matches('-').to_string()
271}
272
273/// Resolve the output path for a template.
274///
275/// If the template has frontmatter with an `output` field, render it with the context.
276/// Otherwise, return None.
277pub fn resolve_template_output_path(
278    template: &LoadedTemplate,
279    cfg: &ResolvedConfig,
280    ctx: &RenderContext,
281) -> Result<Option<PathBuf>, TemplateRenderError> {
282    if let Some(ref fm) = template.frontmatter
283        && let Some(ref output) = fm.output
284    {
285        let rendered = render_string(output, ctx)?;
286        let path = cfg.vault_root.join(&rendered);
287        return Ok(Some(path));
288    }
289    Ok(None)
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_slugify_basic() {
298        assert_eq!(slugify("Hello World"), "hello-world");
299        assert_eq!(slugify("Test Task"), "test-task");
300    }
301
302    #[test]
303    fn test_slugify_special_chars() {
304        assert_eq!(slugify("Hello, World!"), "hello-world");
305        assert_eq!(slugify("What's up?"), "whats-up");
306        assert_eq!(slugify("foo@bar.com"), "foobarcom");
307    }
308
309    #[test]
310    fn test_slugify_underscores() {
311        assert_eq!(slugify("hello_world"), "hello-world");
312        assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
313    }
314
315    #[test]
316    fn test_slugify_multiple_spaces() {
317        assert_eq!(slugify("hello   world"), "hello-world");
318        assert_eq!(slugify("  leading and trailing  "), "leading-and-trailing");
319    }
320
321    #[test]
322    fn test_slugify_mixed() {
323        assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
324        assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
325    }
326
327    #[test]
328    fn test_render_string_with_slugify_filter() {
329        let mut ctx = RenderContext::new();
330        ctx.insert("title".into(), "Hello World".into());
331
332        let result = render_string("{{title | slugify}}", &ctx).unwrap();
333        assert_eq!(result, "hello-world");
334    }
335
336    #[test]
337    fn test_render_string_with_lowercase_filter() {
338        let mut ctx = RenderContext::new();
339        ctx.insert("name".into(), "HELLO".into());
340
341        let result = render_string("{{name | lowercase}}", &ctx).unwrap();
342        assert_eq!(result, "hello");
343
344        let result = render_string("{{name | lower}}", &ctx).unwrap();
345        assert_eq!(result, "hello");
346    }
347
348    #[test]
349    fn test_render_string_with_uppercase_filter() {
350        let mut ctx = RenderContext::new();
351        ctx.insert("name".into(), "hello".into());
352
353        let result = render_string("{{name | uppercase}}", &ctx).unwrap();
354        assert_eq!(result, "HELLO");
355    }
356
357    #[test]
358    fn test_render_string_filter_in_path() {
359        let mut ctx = RenderContext::new();
360        ctx.insert("vault_root".into(), "/vault".into());
361        ctx.insert("title".into(), "My New Task".into());
362
363        let result =
364            render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
365        assert_eq!(result, "/vault/tasks/my-new-task.md");
366    }
367
368    #[test]
369    fn test_render_string_unknown_filter() {
370        let mut ctx = RenderContext::new();
371        ctx.insert("name".into(), "hello".into());
372
373        // Unknown filter returns value unchanged
374        let result = render_string("{{name | unknown}}", &ctx).unwrap();
375        assert_eq!(result, "hello");
376    }
377
378    #[test]
379    fn test_render_string_missing_var_with_filter() {
380        let ctx = RenderContext::new();
381
382        // Missing variable with filter returns original placeholder
383        let result = render_string("{{missing | slugify}}", &ctx).unwrap();
384        assert_eq!(result, "{{missing | slugify}}");
385    }
386
387    #[test]
388    fn test_date_format_not_parsed_as_filter() {
389        let ctx = RenderContext::new();
390
391        // Date expressions with format should still work
392        let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
393        // Should be a date, not "today" with filter "%Y-%m-%d"
394        assert!(result.contains('-'));
395        assert!(!result.contains("today"));
396    }
397}