Skip to main content

mdvault_core/templates/
engine.rs

1use regex::Regex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use chrono::{Local, NaiveDate};
6use thiserror::Error;
7use tracing::debug;
8
9use crate::config::types::ResolvedConfig;
10use crate::vars::datemath::{evaluate_date_expr_with_ref, 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
83/// Clean up YAML content by removing problematic lines and quoting special values.
84///
85/// This handles two cases:
86/// 1. Unreplaced template variables (e.g., `field: {{var}}`) - removes the line
87/// 2. YAML-problematic values (e.g., `field: -`) - quotes the value
88///
89/// Examples:
90/// - `status: {{status}}` where status wasn't provided -> line removed
91/// - `status: todo` where status was provided -> line kept
92/// - `phone: -` -> becomes `phone: "-"` (quoted to avoid YAML list marker interpretation)
93fn remove_unreplaced_vars(content: &str) -> String {
94    content
95        .lines()
96        .filter_map(|line| {
97            // Check if line has a key-value pair
98            if let Some((key, value)) = line.split_once(':') {
99                let value = value.trim();
100
101                // Case 1: Unreplaced template variable - remove line
102                if value.starts_with("{{")
103                    && value.ends_with("}}")
104                    && !value.contains(' ')
105                {
106                    return None;
107                }
108
109                // Case 2: YAML-problematic values - quote them
110                // Check if value needs quoting (single dash, or starts with special YAML chars)
111                if needs_yaml_quoting(value) {
112                    return Some(format!("{}: \"{}\"", key, value));
113                }
114            }
115            Some(line.to_string())
116        })
117        .collect::<Vec<_>>()
118        .join("\n")
119        + "\n" // Add trailing newline
120}
121
122/// Check if a YAML value needs quoting to avoid parsing errors.
123///
124/// Returns true for values that would be misinterpreted or cause parsing errors:
125/// - List markers: `-` (single dash starts a list item)
126/// - Empty string: becomes null without quotes
127///
128/// Note: YAML booleans (true/false/yes/no/on/off) and null values (null/~)
129/// are NOT quoted because they are valid YAML values. Templates with these
130/// values will have them parsed correctly as their respective types.
131fn needs_yaml_quoting(value: &str) -> bool {
132    // Already quoted - no need to quote again
133    if (value.starts_with('"') && value.ends_with('"'))
134        || (value.starts_with('\'') && value.ends_with('\''))
135    {
136        return false;
137    }
138
139    // Single dash is a list marker in YAML - must be quoted
140    if value == "-" {
141        return true;
142    }
143
144    // Empty string becomes null in YAML - quote to preserve as empty string
145    if value.is_empty() {
146        return true;
147    }
148
149    false
150}
151
152pub fn render(
153    template: &LoadedTemplate,
154    ctx: &RenderContext,
155) -> Result<String, TemplateRenderError> {
156    render_with_ref_date(template, ctx, None)
157}
158
159/// Render a template with an optional reference date for date expressions.
160pub fn render_with_ref_date(
161    template: &LoadedTemplate,
162    ctx: &RenderContext,
163    ref_date: Option<NaiveDate>,
164) -> Result<String, TemplateRenderError> {
165    debug!("Rendering template '{}' with vars: {:?}", template.logical_name, ctx.keys());
166    let rendered_body = render_string_with_ref_date(&template.body, ctx, ref_date)?;
167
168    // Check if template has frontmatter to include in output.
169    // We render from the RAW frontmatter text to avoid YAML parsing issues
170    // with template variables like {{title}} being interpreted as YAML mappings.
171    if let Some(ref raw_fm) = template.raw_frontmatter {
172        // Filter out template-specific fields (output, lua, vars)
173        let filtered_fm = filter_template_fields(raw_fm);
174        if !filtered_fm.trim().is_empty() {
175            // Render variables in the filtered frontmatter text
176            let rendered_fm = render_string_with_ref_date(&filtered_fm, ctx, ref_date)?;
177            // Remove lines with unreplaced template variables (optional fields)
178            let cleaned_fm = remove_unreplaced_vars(&rendered_fm);
179            return Ok(format!("---\n{}---\n\n{}", cleaned_fm, rendered_body));
180        }
181    }
182
183    Ok(rendered_body)
184}
185
186/// Filter out template-specific fields (output, lua, vars) from raw frontmatter.
187/// These fields are used by the template system and should not appear in output.
188fn filter_template_fields(raw_fm: &str) -> String {
189    let template_fields = ["output:", "lua:", "vars:"];
190    let mut result = Vec::new();
191    let mut skip_until_next_field = false;
192
193    for line in raw_fm.lines() {
194        // Check if this line starts a template-specific field
195        let trimmed = line.trim_start();
196        let starts_template_field =
197            template_fields.iter().any(|f| trimmed.starts_with(f));
198
199        if starts_template_field {
200            // Start skipping this field and any continuation lines
201            skip_until_next_field = true;
202            continue;
203        }
204
205        // If we're skipping continuation lines from a template field
206        if skip_until_next_field {
207            // Check if this is a new top-level field (not indented)
208            // A new field starts at column 0 and contains a colon
209            let is_new_field = !line.starts_with(' ')
210                && !line.starts_with('\t')
211                && !line.trim().is_empty()
212                && line.contains(':');
213
214            if is_new_field {
215                // This is a new field, stop skipping and include this line
216                skip_until_next_field = false;
217                result.push(line);
218            }
219            // Continue to next line (either included the new field or skipped continuation)
220            continue;
221        }
222
223        // Not skipping, include this line
224        result.push(line);
225    }
226
227    let mut filtered = result.join("\n");
228    // Ensure trailing newline if original had one
229    if raw_fm.ends_with('\n') && !filtered.ends_with('\n') {
230        filtered.push('\n');
231    }
232    filtered
233}
234
235/// Render a string template with variable substitution.
236///
237/// Supports:
238/// - Simple variables: `{{var_name}}`
239/// - Date math expressions: `{{today + 1d}}`, `{{now - 2h}}`, `{{today | %Y-%m-%d}}`
240/// - Filters: `{{var_name | filter}}` (currently supports: slugify)
241pub fn render_string(
242    template: &str,
243    ctx: &RenderContext,
244) -> Result<String, TemplateRenderError> {
245    render_string_with_ref_date(template, ctx, None)
246}
247
248/// Render a string template with an optional reference date for date expressions.
249/// When `ref_date` is Some, relative date expressions (today, date, week, etc.)
250/// evaluate relative to that date instead of system time.
251pub fn render_string_with_ref_date(
252    template: &str,
253    ctx: &RenderContext,
254    ref_date: Option<NaiveDate>,
255) -> Result<String, TemplateRenderError> {
256    // Match both simple vars and date math expressions
257    // Captures everything between {{ and }} that looks like a valid expression
258    let re = Regex::new(r"\{\{([^{}]+)\}\}")
259        .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
260
261    let result = re.replace_all(template, |caps: &regex::Captures<'_>| {
262        let expr = caps[1].trim();
263
264        // Check for filter syntax first: "var_name | filter"
265        if let Some((var_name, filter)) = parse_filter_expr(expr) {
266            if let Some(value) = ctx.get(var_name) {
267                return apply_filter(value, filter);
268            }
269            // Variable not found, but might be a date expression with format
270            // (e.g., "today | %Y-%m-%d")
271            if is_date_expr(expr)
272                && let Ok(parsed) = parse_date_expr(expr)
273            {
274                return evaluate_date_expr_with_ref(&parsed, ref_date);
275            }
276            debug!("Template variable not found for filter: {}", var_name);
277            return caps[0].to_string();
278        }
279
280        // Check context variable FIRST - if explicitly set, use it
281        // This allows variables like "week" or "date" to override date expressions
282        if let Some(val) = ctx.get(expr) {
283            return val.clone();
284        }
285
286        // If no context variable, check if it's a date math expression
287        if is_date_expr(expr)
288            && let Ok(parsed) = parse_date_expr(expr)
289        {
290            return evaluate_date_expr_with_ref(&parsed, ref_date);
291        }
292
293        // Not found anywhere
294        debug!("Template variable not found: {}", expr);
295        caps[0].to_string()
296    });
297
298    Ok(result.into_owned())
299}
300
301/// Parse a filter expression like "var_name | filter_name".
302/// Returns (var_name, filter_name) if valid, None otherwise.
303fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
304    let parts: Vec<&str> = expr.splitn(2, '|').collect();
305    if parts.len() != 2 {
306        return None;
307    }
308
309    let var_name = parts[0].trim();
310    let filter = parts[1].trim();
311    if var_name.is_empty() || filter.is_empty() {
312        return None;
313    }
314
315    // Date format specifiers start with '%' (e.g., "today | %Y-%m-%d").
316    // These are date expressions, not variable filters.
317    if filter.starts_with('%') {
318        return None;
319    }
320
321    // Named filters (slugify, year, etc.) are always variable filters,
322    // even when the variable name happens to be a date keyword like "date".
323    Some((var_name, filter))
324}
325
326/// Apply a filter to a value.
327fn apply_filter(value: &str, filter: &str) -> String {
328    match filter {
329        "slugify" => slugify(value),
330        "lowercase" | "lower" => value.to_lowercase(),
331        "uppercase" | "upper" => value.to_uppercase(),
332        "trim" => value.trim().to_string(),
333        "year" => value.chars().take(4).collect(),
334        _ => value.to_string(), // Unknown filter, return unchanged
335    }
336}
337
338/// Convert a string to a URL-friendly slug.
339///
340/// - Converts to lowercase
341/// - Replaces spaces and underscores with hyphens
342/// - Removes non-alphanumeric characters (except hyphens)
343/// - Collapses multiple hyphens into one
344/// - Trims leading/trailing hyphens
345fn slugify(s: &str) -> String {
346    let mut result = String::with_capacity(s.len());
347
348    for c in s.chars() {
349        if c.is_ascii_alphanumeric() {
350            result.push(c.to_ascii_lowercase());
351        } else if c == ' ' || c == '_' || c == '-' {
352            // Only add hyphen if last char wasn't already a hyphen
353            if !result.ends_with('-') {
354                result.push('-');
355            }
356        }
357        // Other characters are skipped
358    }
359
360    // Trim leading/trailing hyphens
361    result.trim_matches('-').to_string()
362}
363
364/// Resolve the output path for a template.
365///
366/// If the template has frontmatter with an `output` field, render it with the context.
367/// Otherwise, return None.
368pub fn resolve_template_output_path(
369    template: &LoadedTemplate,
370    cfg: &ResolvedConfig,
371    ctx: &RenderContext,
372) -> Result<Option<PathBuf>, TemplateRenderError> {
373    if let Some(ref fm) = template.frontmatter
374        && let Some(ref output) = fm.output
375    {
376        let rendered = render_string(output, ctx)?;
377        let path = cfg.vault_root.join(&rendered);
378        return Ok(Some(path));
379    }
380    Ok(None)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_slugify_basic() {
389        assert_eq!(slugify("Hello World"), "hello-world");
390        assert_eq!(slugify("Test Task"), "test-task");
391    }
392
393    #[test]
394    fn test_slugify_special_chars() {
395        assert_eq!(slugify("Hello, World!"), "hello-world");
396        assert_eq!(slugify("What's up?"), "whats-up");
397        assert_eq!(slugify("foo@bar.com"), "foobarcom");
398    }
399
400    #[test]
401    fn test_slugify_underscores() {
402        assert_eq!(slugify("hello_world"), "hello-world");
403        assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
404    }
405
406    #[test]
407    fn test_slugify_multiple_spaces() {
408        assert_eq!(slugify("hello   world"), "hello-world");
409        assert_eq!(slugify("  leading and trailing  "), "leading-and-trailing");
410    }
411
412    #[test]
413    fn test_slugify_mixed() {
414        assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
415        assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
416    }
417
418    #[test]
419    fn test_render_string_with_slugify_filter() {
420        let mut ctx = RenderContext::new();
421        ctx.insert("title".into(), "Hello World".into());
422
423        let result = render_string("{{title | slugify}}", &ctx).unwrap();
424        assert_eq!(result, "hello-world");
425    }
426
427    #[test]
428    fn test_render_string_with_lowercase_filter() {
429        let mut ctx = RenderContext::new();
430        ctx.insert("name".into(), "HELLO".into());
431
432        let result = render_string("{{name | lowercase}}", &ctx).unwrap();
433        assert_eq!(result, "hello");
434
435        let result = render_string("{{name | lower}}", &ctx).unwrap();
436        assert_eq!(result, "hello");
437    }
438
439    #[test]
440    fn test_render_string_with_uppercase_filter() {
441        let mut ctx = RenderContext::new();
442        ctx.insert("name".into(), "hello".into());
443
444        let result = render_string("{{name | uppercase}}", &ctx).unwrap();
445        assert_eq!(result, "HELLO");
446    }
447
448    #[test]
449    fn test_render_string_filter_in_path() {
450        let mut ctx = RenderContext::new();
451        ctx.insert("vault_root".into(), "/vault".into());
452        ctx.insert("title".into(), "My New Task".into());
453
454        let result =
455            render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
456        assert_eq!(result, "/vault/tasks/my-new-task.md");
457    }
458
459    #[test]
460    fn test_render_string_with_year_filter() {
461        let mut ctx = RenderContext::new();
462        ctx.insert("period".into(), "2026-02".into());
463
464        let result =
465            render_string("Journal/{{period | year}}/Monthly/{{period}}.md", &ctx)
466                .unwrap();
467        assert_eq!(result, "Journal/2026/Monthly/2026-02.md");
468
469        // Also works with YYYY-MM-DD
470        ctx.insert("date".into(), "2026-02-20".into());
471        let result = render_string("{{date | year}}", &ctx).unwrap();
472        assert_eq!(result, "2026");
473
474        // And YYYY-Www
475        ctx.insert("week".into(), "2026-W08".into());
476        let result = render_string("{{week | year}}", &ctx).unwrap();
477        assert_eq!(result, "2026");
478    }
479
480    #[test]
481    fn test_render_string_unknown_filter() {
482        let mut ctx = RenderContext::new();
483        ctx.insert("name".into(), "hello".into());
484
485        // Unknown filter returns value unchanged
486        let result = render_string("{{name | unknown}}", &ctx).unwrap();
487        assert_eq!(result, "hello");
488    }
489
490    #[test]
491    fn test_render_string_missing_var_with_filter() {
492        let ctx = RenderContext::new();
493
494        // Missing variable with filter returns original placeholder
495        let result = render_string("{{missing | slugify}}", &ctx).unwrap();
496        assert_eq!(result, "{{missing | slugify}}");
497    }
498
499    #[test]
500    fn test_date_format_not_parsed_as_filter() {
501        let ctx = RenderContext::new();
502
503        // Date expressions with format should still work
504        let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
505        // Should be a date, not "today" with filter "%Y-%m-%d"
506        assert!(result.contains('-'));
507        assert!(!result.contains("today"));
508    }
509
510    #[test]
511    fn test_context_variable_overrides_date_expression() {
512        // Regression test: context variables should take precedence over date expressions
513        // This is important for templates like "{{week}}" where a computed week value
514        // should be used instead of evaluating "week" as a date expression
515        let mut ctx = RenderContext::new();
516
517        // Set a "week" variable that should override the "week" date expression
518        ctx.insert("week".into(), "2026-W06".into());
519        let result = render_string("Journal/Weekly/{{week}}.md", &ctx).unwrap();
520        assert_eq!(result, "Journal/Weekly/2026-W06.md");
521
522        // Same for "date" - context variable should override date expression
523        ctx.insert("date".into(), "2026-02-15".into());
524        let result = render_string("Journal/Daily/{{date}}.md", &ctx).unwrap();
525        assert_eq!(result, "Journal/Daily/2026-02-15.md");
526
527        // Without context variable, date expression should still work
528        let empty_ctx = RenderContext::new();
529        let result = render_string("{{today}}", &empty_ctx).unwrap();
530        // Should be today's date, not "{{today}}"
531        assert!(result.contains('-') && result.len() == 10);
532    }
533
534    #[test]
535    fn test_remove_unreplaced_vars() {
536        // Test removing unreplaced template variables
537        let content = "status: todo\nphone: {{phone}}\nemail: test@example.com\n";
538        let result = super::remove_unreplaced_vars(content);
539        assert!(result.contains("status: todo"));
540        assert!(!result.contains("phone:"), "unreplaced var line should be removed");
541        assert!(result.contains("email: test@example.com"));
542    }
543
544    #[test]
545    fn test_remove_unreplaced_vars_quotes_dash() {
546        // Test quoting of YAML-problematic dash value
547        let content = "name: John\nphone: -\nemail: test@example.com\n";
548        let result = super::remove_unreplaced_vars(content);
549        assert!(result.contains("name: John"));
550        assert!(
551            result.contains("phone: \"-\""),
552            "dash should be quoted, got: {}",
553            result
554        );
555        assert!(result.contains("email: test@example.com"));
556    }
557
558    #[test]
559    fn test_needs_yaml_quoting() {
560        use super::needs_yaml_quoting;
561
562        // Should need quoting - only values that cause parsing errors
563        assert!(needs_yaml_quoting("-")); // List marker
564        assert!(needs_yaml_quoting("")); // Empty string becomes null
565
566        // Should NOT need quoting - valid YAML values
567        assert!(!needs_yaml_quoting("hello"));
568        assert!(!needs_yaml_quoting("123"));
569        assert!(!needs_yaml_quoting("\"already quoted\""));
570        assert!(!needs_yaml_quoting("'already quoted'"));
571        assert!(!needs_yaml_quoting("test@example.com"));
572
573        // YAML booleans should NOT be quoted (they're valid YAML)
574        assert!(!needs_yaml_quoting("true"));
575        assert!(!needs_yaml_quoting("false"));
576        assert!(!needs_yaml_quoting("yes"));
577        assert!(!needs_yaml_quoting("no"));
578
579        // YAML null values should NOT be quoted (they're valid YAML)
580        assert!(!needs_yaml_quoting("null"));
581        assert!(!needs_yaml_quoting("~"));
582    }
583}