mdvault_core/templates/
engine.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use chrono::Local;
6use serde_yaml::Value;
7use thiserror::Error;
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    let rendered_body = render_string(&template.body, ctx)?;
88
89    // Check if template has extra frontmatter fields to include in output
90    // Note: can't use let chains (Rust 2024) so we nest the if statements
91    #[allow(clippy::collapsible_if)]
92    if let Some(ref fm) = template.frontmatter {
93        if !fm.extra.is_empty() {
94            // Render variable placeholders in frontmatter values
95            let rendered_fm = render_frontmatter_values(&fm.extra, ctx)?;
96            // Serialize as YAML frontmatter
97            let yaml = serde_yaml::to_string(&rendered_fm).unwrap_or_default();
98            return Ok(format!("---\n{}---\n\n{}", yaml, rendered_body));
99        }
100    }
101
102    Ok(rendered_body)
103}
104
105/// Render variable placeholders in frontmatter values.
106fn render_frontmatter_values(
107    fields: &HashMap<String, Value>,
108    ctx: &RenderContext,
109) -> Result<HashMap<String, Value>, TemplateRenderError> {
110    let mut rendered = HashMap::new();
111    for (key, value) in fields {
112        let rendered_value = render_yaml_value(value, ctx)?;
113        rendered.insert(key.clone(), rendered_value);
114    }
115    Ok(rendered)
116}
117
118/// Recursively render variable placeholders in a YAML value.
119fn render_yaml_value(
120    value: &Value,
121    ctx: &RenderContext,
122) -> Result<Value, TemplateRenderError> {
123    match value {
124        Value::String(s) => {
125            let rendered = render_string(s, ctx)?;
126            Ok(Value::String(rendered))
127        }
128        Value::Sequence(seq) => {
129            let rendered: Result<Vec<Value>, _> =
130                seq.iter().map(|v| render_yaml_value(v, ctx)).collect();
131            Ok(Value::Sequence(rendered?))
132        }
133        Value::Mapping(map) => {
134            let mut rendered_map = serde_yaml::Mapping::new();
135            for (k, v) in map {
136                let rendered_v = render_yaml_value(v, ctx)?;
137                rendered_map.insert(k.clone(), rendered_v);
138            }
139            Ok(Value::Mapping(rendered_map))
140        }
141        // Other types (numbers, bools, null) pass through unchanged
142        _ => Ok(value.clone()),
143    }
144}
145
146/// Render a string template with variable substitution.
147///
148/// Supports:
149/// - Simple variables: `{{var_name}}`
150/// - Date math expressions: `{{today + 1d}}`, `{{now - 2h}}`, `{{today | %Y-%m-%d}}`
151/// - Filters: `{{var_name | filter}}` (currently supports: slugify)
152pub fn render_string(
153    template: &str,
154    ctx: &RenderContext,
155) -> Result<String, TemplateRenderError> {
156    // Match both simple vars and date math expressions
157    // Captures everything between {{ and }} that looks like a valid expression
158    let re = Regex::new(r"\{\{([^{}]+)\}\}")
159        .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
160
161    let result = re.replace_all(template, |caps: &regex::Captures<'_>| {
162        let expr = caps[1].trim();
163
164        // First, check if it's a date math expression
165        if is_date_expr(expr)
166            && let Ok(parsed) = parse_date_expr(expr)
167        {
168            return evaluate_date_expr(&parsed);
169        }
170
171        // Check for filter syntax: "var_name | filter"
172        if let Some((var_name, filter)) = parse_filter_expr(expr) {
173            if let Some(value) = ctx.get(var_name) {
174                return apply_filter(value, filter);
175            }
176            // Variable not found, return original
177            return caps[0].to_string();
178        }
179
180        // Otherwise, try simple variable lookup
181        ctx.get(expr).cloned().unwrap_or_else(|| caps[0].to_string())
182    });
183
184    Ok(result.into_owned())
185}
186
187/// Parse a filter expression like "var_name | filter_name".
188/// Returns (var_name, filter_name) if valid, None otherwise.
189fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
190    // Don't parse date expressions with format as filters (e.g., "today | %Y-%m-%d")
191    if is_date_expr(expr) {
192        return None;
193    }
194
195    let parts: Vec<&str> = expr.splitn(2, '|').collect();
196    if parts.len() == 2 {
197        let var_name = parts[0].trim();
198        let filter = parts[1].trim();
199        if !var_name.is_empty() && !filter.is_empty() {
200            return Some((var_name, filter));
201        }
202    }
203    None
204}
205
206/// Apply a filter to a value.
207fn apply_filter(value: &str, filter: &str) -> String {
208    match filter {
209        "slugify" => slugify(value),
210        "lowercase" | "lower" => value.to_lowercase(),
211        "uppercase" | "upper" => value.to_uppercase(),
212        "trim" => value.trim().to_string(),
213        _ => value.to_string(), // Unknown filter, return unchanged
214    }
215}
216
217/// Convert a string to a URL-friendly slug.
218///
219/// - Converts to lowercase
220/// - Replaces spaces and underscores with hyphens
221/// - Removes non-alphanumeric characters (except hyphens)
222/// - Collapses multiple hyphens into one
223/// - Trims leading/trailing hyphens
224fn slugify(s: &str) -> String {
225    let mut result = String::with_capacity(s.len());
226
227    for c in s.chars() {
228        if c.is_ascii_alphanumeric() {
229            result.push(c.to_ascii_lowercase());
230        } else if c == ' ' || c == '_' || c == '-' {
231            // Only add hyphen if last char wasn't already a hyphen
232            if !result.ends_with('-') {
233                result.push('-');
234            }
235        }
236        // Other characters are skipped
237    }
238
239    // Trim leading/trailing hyphens
240    result.trim_matches('-').to_string()
241}
242
243/// Resolve the output path for a template.
244///
245/// If the template has frontmatter with an `output` field, render it with the context.
246/// Otherwise, return None.
247pub fn resolve_template_output_path(
248    template: &LoadedTemplate,
249    cfg: &ResolvedConfig,
250    ctx: &RenderContext,
251) -> Result<Option<PathBuf>, TemplateRenderError> {
252    if let Some(ref fm) = template.frontmatter
253        && let Some(ref output) = fm.output
254    {
255        let rendered = render_string(output, ctx)?;
256        let path = cfg.vault_root.join(&rendered);
257        return Ok(Some(path));
258    }
259    Ok(None)
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_slugify_basic() {
268        assert_eq!(slugify("Hello World"), "hello-world");
269        assert_eq!(slugify("Test Task"), "test-task");
270    }
271
272    #[test]
273    fn test_slugify_special_chars() {
274        assert_eq!(slugify("Hello, World!"), "hello-world");
275        assert_eq!(slugify("What's up?"), "whats-up");
276        assert_eq!(slugify("foo@bar.com"), "foobarcom");
277    }
278
279    #[test]
280    fn test_slugify_underscores() {
281        assert_eq!(slugify("hello_world"), "hello-world");
282        assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
283    }
284
285    #[test]
286    fn test_slugify_multiple_spaces() {
287        assert_eq!(slugify("hello   world"), "hello-world");
288        assert_eq!(slugify("  leading and trailing  "), "leading-and-trailing");
289    }
290
291    #[test]
292    fn test_slugify_mixed() {
293        assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
294        assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
295    }
296
297    #[test]
298    fn test_render_string_with_slugify_filter() {
299        let mut ctx = RenderContext::new();
300        ctx.insert("title".into(), "Hello World".into());
301
302        let result = render_string("{{title | slugify}}", &ctx).unwrap();
303        assert_eq!(result, "hello-world");
304    }
305
306    #[test]
307    fn test_render_string_with_lowercase_filter() {
308        let mut ctx = RenderContext::new();
309        ctx.insert("name".into(), "HELLO".into());
310
311        let result = render_string("{{name | lowercase}}", &ctx).unwrap();
312        assert_eq!(result, "hello");
313
314        let result = render_string("{{name | lower}}", &ctx).unwrap();
315        assert_eq!(result, "hello");
316    }
317
318    #[test]
319    fn test_render_string_with_uppercase_filter() {
320        let mut ctx = RenderContext::new();
321        ctx.insert("name".into(), "hello".into());
322
323        let result = render_string("{{name | uppercase}}", &ctx).unwrap();
324        assert_eq!(result, "HELLO");
325    }
326
327    #[test]
328    fn test_render_string_filter_in_path() {
329        let mut ctx = RenderContext::new();
330        ctx.insert("vault_root".into(), "/vault".into());
331        ctx.insert("title".into(), "My New Task".into());
332
333        let result =
334            render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
335        assert_eq!(result, "/vault/tasks/my-new-task.md");
336    }
337
338    #[test]
339    fn test_render_string_unknown_filter() {
340        let mut ctx = RenderContext::new();
341        ctx.insert("name".into(), "hello".into());
342
343        // Unknown filter returns value unchanged
344        let result = render_string("{{name | unknown}}", &ctx).unwrap();
345        assert_eq!(result, "hello");
346    }
347
348    #[test]
349    fn test_render_string_missing_var_with_filter() {
350        let ctx = RenderContext::new();
351
352        // Missing variable with filter returns original placeholder
353        let result = render_string("{{missing | slugify}}", &ctx).unwrap();
354        assert_eq!(result, "{{missing | slugify}}");
355    }
356
357    #[test]
358    fn test_date_format_not_parsed_as_filter() {
359        let ctx = RenderContext::new();
360
361        // Date expressions with format should still work
362        let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
363        // Should be a date, not "today" with filter "%Y-%m-%d"
364        assert!(result.contains('-'));
365        assert!(!result.contains("today"));
366    }
367}