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
23pub fn build_minimal_context(
28 cfg: &ResolvedConfig,
29 template: &TemplateInfo,
30) -> RenderContext {
31 let mut ctx = RenderContext::new();
32
33 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 ctx.insert("today".into(), now.format("%Y-%m-%d").to_string());
40 ctx.insert("now".into(), now.to_rfc3339());
41
42 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 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 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 if template.frontmatter.is_some()
94 && let Some(raw_fm) = extract_raw_frontmatter(&template.content)
95 {
96 let filtered_fm = filter_template_fields(&raw_fm);
98 if !filtered_fm.trim().is_empty() {
99 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
108fn 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 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
131fn 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 let trimmed = line.trim_start();
141 let starts_field = template_fields.iter().any(|f| trimmed.starts_with(f));
142
143 if starts_field {
144 skip_until_next_field = true;
146 continue;
147 }
148
149 if skip_until_next_field {
151 if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
153 continue;
154 }
155 skip_until_next_field = false;
157 }
158
159 result.push(line);
160 }
161
162 let mut filtered = result.join("\n");
163 if raw_fm.ends_with('\n') && !filtered.ends_with('\n') {
165 filtered.push('\n');
166 }
167 filtered
168}
169
170pub fn render_string(
177 template: &str,
178 ctx: &RenderContext,
179) -> Result<String, TemplateRenderError> {
180 let re = Regex::new(r"\{\{([^{}]+)\}\}")
183 .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
184
185 let result = re.replace_all(template, |caps: ®ex::Captures<'_>| {
186 let expr = caps[1].trim();
187
188 if is_date_expr(expr)
190 && let Ok(parsed) = parse_date_expr(expr)
191 {
192 return evaluate_date_expr(&parsed);
193 }
194
195 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 return caps[0].to_string();
203 }
204
205 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
217fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
220 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
236fn 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(), }
245}
246
247fn 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 if !result.ends_with('-') {
263 result.push('-');
264 }
265 }
266 }
268
269 result.trim_matches('-').to_string()
271}
272
273pub 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 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 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 let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
393 assert!(result.contains('-'));
395 assert!(!result.contains("today"));
396 }
397}