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 if is_date_expr(expr) {
306 return None;
307 }
308
309 let parts: Vec<&str> = expr.splitn(2, '|').collect();
310 if parts.len() == 2 {
311 let var_name = parts[0].trim();
312 let filter = parts[1].trim();
313 if !var_name.is_empty() && !filter.is_empty() {
314 return Some((var_name, filter));
315 }
316 }
317 None
318}
319
320fn apply_filter(value: &str, filter: &str) -> String {
322 match filter {
323 "slugify" => slugify(value),
324 "lowercase" | "lower" => value.to_lowercase(),
325 "uppercase" | "upper" => value.to_uppercase(),
326 "trim" => value.trim().to_string(),
327 _ => value.to_string(), }
329}
330
331fn slugify(s: &str) -> String {
339 let mut result = String::with_capacity(s.len());
340
341 for c in s.chars() {
342 if c.is_ascii_alphanumeric() {
343 result.push(c.to_ascii_lowercase());
344 } else if c == ' ' || c == '_' || c == '-' {
345 if !result.ends_with('-') {
347 result.push('-');
348 }
349 }
350 }
352
353 result.trim_matches('-').to_string()
355}
356
357pub fn resolve_template_output_path(
362 template: &LoadedTemplate,
363 cfg: &ResolvedConfig,
364 ctx: &RenderContext,
365) -> Result<Option<PathBuf>, TemplateRenderError> {
366 if let Some(ref fm) = template.frontmatter
367 && let Some(ref output) = fm.output
368 {
369 let rendered = render_string(output, ctx)?;
370 let path = cfg.vault_root.join(&rendered);
371 return Ok(Some(path));
372 }
373 Ok(None)
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn test_slugify_basic() {
382 assert_eq!(slugify("Hello World"), "hello-world");
383 assert_eq!(slugify("Test Task"), "test-task");
384 }
385
386 #[test]
387 fn test_slugify_special_chars() {
388 assert_eq!(slugify("Hello, World!"), "hello-world");
389 assert_eq!(slugify("What's up?"), "whats-up");
390 assert_eq!(slugify("foo@bar.com"), "foobarcom");
391 }
392
393 #[test]
394 fn test_slugify_underscores() {
395 assert_eq!(slugify("hello_world"), "hello-world");
396 assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
397 }
398
399 #[test]
400 fn test_slugify_multiple_spaces() {
401 assert_eq!(slugify("hello world"), "hello-world");
402 assert_eq!(slugify(" leading and trailing "), "leading-and-trailing");
403 }
404
405 #[test]
406 fn test_slugify_mixed() {
407 assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
408 assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
409 }
410
411 #[test]
412 fn test_render_string_with_slugify_filter() {
413 let mut ctx = RenderContext::new();
414 ctx.insert("title".into(), "Hello World".into());
415
416 let result = render_string("{{title | slugify}}", &ctx).unwrap();
417 assert_eq!(result, "hello-world");
418 }
419
420 #[test]
421 fn test_render_string_with_lowercase_filter() {
422 let mut ctx = RenderContext::new();
423 ctx.insert("name".into(), "HELLO".into());
424
425 let result = render_string("{{name | lowercase}}", &ctx).unwrap();
426 assert_eq!(result, "hello");
427
428 let result = render_string("{{name | lower}}", &ctx).unwrap();
429 assert_eq!(result, "hello");
430 }
431
432 #[test]
433 fn test_render_string_with_uppercase_filter() {
434 let mut ctx = RenderContext::new();
435 ctx.insert("name".into(), "hello".into());
436
437 let result = render_string("{{name | uppercase}}", &ctx).unwrap();
438 assert_eq!(result, "HELLO");
439 }
440
441 #[test]
442 fn test_render_string_filter_in_path() {
443 let mut ctx = RenderContext::new();
444 ctx.insert("vault_root".into(), "/vault".into());
445 ctx.insert("title".into(), "My New Task".into());
446
447 let result =
448 render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
449 assert_eq!(result, "/vault/tasks/my-new-task.md");
450 }
451
452 #[test]
453 fn test_render_string_unknown_filter() {
454 let mut ctx = RenderContext::new();
455 ctx.insert("name".into(), "hello".into());
456
457 let result = render_string("{{name | unknown}}", &ctx).unwrap();
459 assert_eq!(result, "hello");
460 }
461
462 #[test]
463 fn test_render_string_missing_var_with_filter() {
464 let ctx = RenderContext::new();
465
466 let result = render_string("{{missing | slugify}}", &ctx).unwrap();
468 assert_eq!(result, "{{missing | slugify}}");
469 }
470
471 #[test]
472 fn test_date_format_not_parsed_as_filter() {
473 let ctx = RenderContext::new();
474
475 let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
477 assert!(result.contains('-'));
479 assert!(!result.contains("today"));
480 }
481
482 #[test]
483 fn test_context_variable_overrides_date_expression() {
484 let mut ctx = RenderContext::new();
488
489 ctx.insert("week".into(), "2026-W06".into());
491 let result = render_string("Journal/Weekly/{{week}}.md", &ctx).unwrap();
492 assert_eq!(result, "Journal/Weekly/2026-W06.md");
493
494 ctx.insert("date".into(), "2026-02-15".into());
496 let result = render_string("Journal/Daily/{{date}}.md", &ctx).unwrap();
497 assert_eq!(result, "Journal/Daily/2026-02-15.md");
498
499 let empty_ctx = RenderContext::new();
501 let result = render_string("{{today}}", &empty_ctx).unwrap();
502 assert!(result.contains('-') && result.len() == 10);
504 }
505
506 #[test]
507 fn test_remove_unreplaced_vars() {
508 let content = "status: todo\nphone: {{phone}}\nemail: test@example.com\n";
510 let result = super::remove_unreplaced_vars(content);
511 assert!(result.contains("status: todo"));
512 assert!(!result.contains("phone:"), "unreplaced var line should be removed");
513 assert!(result.contains("email: test@example.com"));
514 }
515
516 #[test]
517 fn test_remove_unreplaced_vars_quotes_dash() {
518 let content = "name: John\nphone: -\nemail: test@example.com\n";
520 let result = super::remove_unreplaced_vars(content);
521 assert!(result.contains("name: John"));
522 assert!(
523 result.contains("phone: \"-\""),
524 "dash should be quoted, got: {}",
525 result
526 );
527 assert!(result.contains("email: test@example.com"));
528 }
529
530 #[test]
531 fn test_needs_yaml_quoting() {
532 use super::needs_yaml_quoting;
533
534 assert!(needs_yaml_quoting("-")); assert!(needs_yaml_quoting("")); assert!(!needs_yaml_quoting("hello"));
540 assert!(!needs_yaml_quoting("123"));
541 assert!(!needs_yaml_quoting("\"already quoted\""));
542 assert!(!needs_yaml_quoting("'already quoted'"));
543 assert!(!needs_yaml_quoting("test@example.com"));
544
545 assert!(!needs_yaml_quoting("true"));
547 assert!(!needs_yaml_quoting("false"));
548 assert!(!needs_yaml_quoting("yes"));
549 assert!(!needs_yaml_quoting("no"));
550
551 assert!(!needs_yaml_quoting("null"));
553 assert!(!needs_yaml_quoting("~"));
554 }
555}