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
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 render_with_ref_date(template, ctx, None)
157}
158
159pub 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 if let Some(ref raw_fm) = template.raw_frontmatter {
172 let filtered_fm = filter_template_fields(raw_fm);
174 if !filtered_fm.trim().is_empty() {
175 let rendered_fm = render_string_with_ref_date(&filtered_fm, ctx, ref_date)?;
177 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
186fn 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 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 skip_until_next_field = true;
202 continue;
203 }
204
205 if skip_until_next_field {
207 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 skip_until_next_field = false;
217 result.push(line);
218 }
219 continue;
221 }
222
223 result.push(line);
225 }
226
227 let mut filtered = result.join("\n");
228 if raw_fm.ends_with('\n') && !filtered.ends_with('\n') {
230 filtered.push('\n');
231 }
232 filtered
233}
234
235pub fn render_string(
242 template: &str,
243 ctx: &RenderContext,
244) -> Result<String, TemplateRenderError> {
245 render_string_with_ref_date(template, ctx, None)
246}
247
248pub fn render_string_with_ref_date(
252 template: &str,
253 ctx: &RenderContext,
254 ref_date: Option<NaiveDate>,
255) -> Result<String, TemplateRenderError> {
256 let re = Regex::new(r"\{\{([^{}]+)\}\}")
259 .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
260
261 let result = re.replace_all(template, |caps: ®ex::Captures<'_>| {
262 let expr = caps[1].trim();
263
264 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 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 if let Some(val) = ctx.get(expr) {
283 return val.clone();
284 }
285
286 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 debug!("Template variable not found: {}", expr);
295 caps[0].to_string()
296 });
297
298 Ok(result.into_owned())
299}
300
301fn 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 if filter.starts_with('%') {
318 return None;
319 }
320
321 Some((var_name, filter))
324}
325
326fn 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(), }
336}
337
338fn 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 if !result.ends_with('-') {
354 result.push('-');
355 }
356 }
357 }
359
360 result.trim_matches('-').to_string()
362}
363
364pub 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 ctx.insert("date".into(), "2026-02-20".into());
471 let result = render_string("{{date | year}}", &ctx).unwrap();
472 assert_eq!(result, "2026");
473
474 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 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 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 let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
505 assert!(result.contains('-'));
507 assert!(!result.contains("today"));
508 }
509
510 #[test]
511 fn test_context_variable_overrides_date_expression() {
512 let mut ctx = RenderContext::new();
516
517 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 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 let empty_ctx = RenderContext::new();
529 let result = render_string("{{today}}", &empty_ctx).unwrap();
530 assert!(result.contains('-') && result.len() == 10);
532 }
533
534 #[test]
535 fn test_remove_unreplaced_vars() {
536 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 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 assert!(needs_yaml_quoting("-")); assert!(needs_yaml_quoting("")); 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 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 assert!(!needs_yaml_quoting("null"));
581 assert!(!needs_yaml_quoting("~"));
582 }
583}