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
83fn remove_unreplaced_vars(content: &str) -> String {
94 content
95 .lines()
96 .filter_map(|line| {
97 if let Some((key, value)) = line.split_once(':') {
99 let value = value.trim();
100
101 if value.starts_with("{{")
103 && value.ends_with("}}")
104 && !value.contains(' ')
105 {
106 return None;
107 }
108
109 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" }
121
122fn needs_yaml_quoting(value: &str) -> bool {
132 if (value.starts_with('"') && value.ends_with('"'))
134 || (value.starts_with('\'') && value.ends_with('\''))
135 {
136 return false;
137 }
138
139 if value == "-" {
141 return true;
142 }
143
144 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 debug!("Rendering template '{}' with vars: {:?}", template.logical_name, ctx.keys());
157 let rendered_body = render_string(&template.body, ctx)?;
158
159 if let Some(ref raw_fm) = template.raw_frontmatter {
163 let filtered_fm = filter_template_fields(raw_fm);
165 if !filtered_fm.trim().is_empty() {
166 let rendered_fm = render_string(&filtered_fm, ctx)?;
168 let cleaned_fm = remove_unreplaced_vars(&rendered_fm);
170 return Ok(format!("---\n{}---\n\n{}", cleaned_fm, rendered_body));
171 }
172 }
173
174 Ok(rendered_body)
175}
176
177fn filter_template_fields(raw_fm: &str) -> String {
180 let template_fields = ["output:", "lua:", "vars:"];
181 let mut result = Vec::new();
182 let mut skip_until_next_field = false;
183
184 for line in raw_fm.lines() {
185 let trimmed = line.trim_start();
187 let starts_template_field =
188 template_fields.iter().any(|f| trimmed.starts_with(f));
189
190 if starts_template_field {
191 skip_until_next_field = true;
193 continue;
194 }
195
196 if skip_until_next_field {
198 let is_new_field = !line.starts_with(' ')
201 && !line.starts_with('\t')
202 && !line.trim().is_empty()
203 && line.contains(':');
204
205 if is_new_field {
206 skip_until_next_field = false;
208 result.push(line);
209 }
210 continue;
212 }
213
214 result.push(line);
216 }
217
218 let mut filtered = result.join("\n");
219 if raw_fm.ends_with('\n') && !filtered.ends_with('\n') {
221 filtered.push('\n');
222 }
223 filtered
224}
225
226pub fn render_string(
233 template: &str,
234 ctx: &RenderContext,
235) -> Result<String, TemplateRenderError> {
236 let re = Regex::new(r"\{\{([^{}]+)\}\}")
239 .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
240
241 let result = re.replace_all(template, |caps: ®ex::Captures<'_>| {
242 let expr = caps[1].trim();
243
244 if let Some((var_name, filter)) = parse_filter_expr(expr) {
246 if let Some(value) = ctx.get(var_name) {
247 return apply_filter(value, filter);
248 }
249 if is_date_expr(expr)
252 && let Ok(parsed) = parse_date_expr(expr)
253 {
254 return evaluate_date_expr(&parsed);
255 }
256 debug!("Template variable not found for filter: {}", var_name);
257 return caps[0].to_string();
258 }
259
260 if let Some(val) = ctx.get(expr) {
263 return val.clone();
264 }
265
266 if is_date_expr(expr)
268 && let Ok(parsed) = parse_date_expr(expr)
269 {
270 return evaluate_date_expr(&parsed);
271 }
272
273 debug!("Template variable not found: {}", expr);
275 caps[0].to_string()
276 });
277
278 Ok(result.into_owned())
279}
280
281fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
284 if is_date_expr(expr) {
286 return None;
287 }
288
289 let parts: Vec<&str> = expr.splitn(2, '|').collect();
290 if parts.len() == 2 {
291 let var_name = parts[0].trim();
292 let filter = parts[1].trim();
293 if !var_name.is_empty() && !filter.is_empty() {
294 return Some((var_name, filter));
295 }
296 }
297 None
298}
299
300fn apply_filter(value: &str, filter: &str) -> String {
302 match filter {
303 "slugify" => slugify(value),
304 "lowercase" | "lower" => value.to_lowercase(),
305 "uppercase" | "upper" => value.to_uppercase(),
306 "trim" => value.trim().to_string(),
307 _ => value.to_string(), }
309}
310
311fn slugify(s: &str) -> String {
319 let mut result = String::with_capacity(s.len());
320
321 for c in s.chars() {
322 if c.is_ascii_alphanumeric() {
323 result.push(c.to_ascii_lowercase());
324 } else if c == ' ' || c == '_' || c == '-' {
325 if !result.ends_with('-') {
327 result.push('-');
328 }
329 }
330 }
332
333 result.trim_matches('-').to_string()
335}
336
337pub fn resolve_template_output_path(
342 template: &LoadedTemplate,
343 cfg: &ResolvedConfig,
344 ctx: &RenderContext,
345) -> Result<Option<PathBuf>, TemplateRenderError> {
346 if let Some(ref fm) = template.frontmatter
347 && let Some(ref output) = fm.output
348 {
349 let rendered = render_string(output, ctx)?;
350 let path = cfg.vault_root.join(&rendered);
351 return Ok(Some(path));
352 }
353 Ok(None)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_slugify_basic() {
362 assert_eq!(slugify("Hello World"), "hello-world");
363 assert_eq!(slugify("Test Task"), "test-task");
364 }
365
366 #[test]
367 fn test_slugify_special_chars() {
368 assert_eq!(slugify("Hello, World!"), "hello-world");
369 assert_eq!(slugify("What's up?"), "whats-up");
370 assert_eq!(slugify("foo@bar.com"), "foobarcom");
371 }
372
373 #[test]
374 fn test_slugify_underscores() {
375 assert_eq!(slugify("hello_world"), "hello-world");
376 assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
377 }
378
379 #[test]
380 fn test_slugify_multiple_spaces() {
381 assert_eq!(slugify("hello world"), "hello-world");
382 assert_eq!(slugify(" leading and trailing "), "leading-and-trailing");
383 }
384
385 #[test]
386 fn test_slugify_mixed() {
387 assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
388 assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
389 }
390
391 #[test]
392 fn test_render_string_with_slugify_filter() {
393 let mut ctx = RenderContext::new();
394 ctx.insert("title".into(), "Hello World".into());
395
396 let result = render_string("{{title | slugify}}", &ctx).unwrap();
397 assert_eq!(result, "hello-world");
398 }
399
400 #[test]
401 fn test_render_string_with_lowercase_filter() {
402 let mut ctx = RenderContext::new();
403 ctx.insert("name".into(), "HELLO".into());
404
405 let result = render_string("{{name | lowercase}}", &ctx).unwrap();
406 assert_eq!(result, "hello");
407
408 let result = render_string("{{name | lower}}", &ctx).unwrap();
409 assert_eq!(result, "hello");
410 }
411
412 #[test]
413 fn test_render_string_with_uppercase_filter() {
414 let mut ctx = RenderContext::new();
415 ctx.insert("name".into(), "hello".into());
416
417 let result = render_string("{{name | uppercase}}", &ctx).unwrap();
418 assert_eq!(result, "HELLO");
419 }
420
421 #[test]
422 fn test_render_string_filter_in_path() {
423 let mut ctx = RenderContext::new();
424 ctx.insert("vault_root".into(), "/vault".into());
425 ctx.insert("title".into(), "My New Task".into());
426
427 let result =
428 render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
429 assert_eq!(result, "/vault/tasks/my-new-task.md");
430 }
431
432 #[test]
433 fn test_render_string_unknown_filter() {
434 let mut ctx = RenderContext::new();
435 ctx.insert("name".into(), "hello".into());
436
437 let result = render_string("{{name | unknown}}", &ctx).unwrap();
439 assert_eq!(result, "hello");
440 }
441
442 #[test]
443 fn test_render_string_missing_var_with_filter() {
444 let ctx = RenderContext::new();
445
446 let result = render_string("{{missing | slugify}}", &ctx).unwrap();
448 assert_eq!(result, "{{missing | slugify}}");
449 }
450
451 #[test]
452 fn test_date_format_not_parsed_as_filter() {
453 let ctx = RenderContext::new();
454
455 let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
457 assert!(result.contains('-'));
459 assert!(!result.contains("today"));
460 }
461
462 #[test]
463 fn test_context_variable_overrides_date_expression() {
464 let mut ctx = RenderContext::new();
468
469 ctx.insert("week".into(), "2026-W06".into());
471 let result = render_string("Journal/Weekly/{{week}}.md", &ctx).unwrap();
472 assert_eq!(result, "Journal/Weekly/2026-W06.md");
473
474 ctx.insert("date".into(), "2026-02-15".into());
476 let result = render_string("Journal/Daily/{{date}}.md", &ctx).unwrap();
477 assert_eq!(result, "Journal/Daily/2026-02-15.md");
478
479 let empty_ctx = RenderContext::new();
481 let result = render_string("{{today}}", &empty_ctx).unwrap();
482 assert!(result.contains('-') && result.len() == 10);
484 }
485
486 #[test]
487 fn test_remove_unreplaced_vars() {
488 let content = "status: todo\nphone: {{phone}}\nemail: test@example.com\n";
490 let result = super::remove_unreplaced_vars(content);
491 assert!(result.contains("status: todo"));
492 assert!(!result.contains("phone:"), "unreplaced var line should be removed");
493 assert!(result.contains("email: test@example.com"));
494 }
495
496 #[test]
497 fn test_remove_unreplaced_vars_quotes_dash() {
498 let content = "name: John\nphone: -\nemail: test@example.com\n";
500 let result = super::remove_unreplaced_vars(content);
501 assert!(result.contains("name: John"));
502 assert!(
503 result.contains("phone: \"-\""),
504 "dash should be quoted, got: {}",
505 result
506 );
507 assert!(result.contains("email: test@example.com"));
508 }
509
510 #[test]
511 fn test_needs_yaml_quoting() {
512 use super::needs_yaml_quoting;
513
514 assert!(needs_yaml_quoting("-")); assert!(needs_yaml_quoting("")); assert!(!needs_yaml_quoting("hello"));
520 assert!(!needs_yaml_quoting("123"));
521 assert!(!needs_yaml_quoting("\"already quoted\""));
522 assert!(!needs_yaml_quoting("'already quoted'"));
523 assert!(!needs_yaml_quoting("test@example.com"));
524
525 assert!(!needs_yaml_quoting("true"));
527 assert!(!needs_yaml_quoting("false"));
528 assert!(!needs_yaml_quoting("yes"));
529 assert!(!needs_yaml_quoting("no"));
530
531 assert!(!needs_yaml_quoting("null"));
533 assert!(!needs_yaml_quoting("~"));
534 }
535}