1use regex::Regex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use chrono::Local;
6use serde_yaml::Value;
7use thiserror::Error;
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 let rendered_body = render_string(&template.body, ctx)?;
88
89 #[allow(clippy::collapsible_if)]
92 if let Some(ref fm) = template.frontmatter {
93 if !fm.extra.is_empty() {
94 let rendered_fm = render_frontmatter_values(&fm.extra, ctx)?;
96 let yaml = serde_yaml::to_string(&rendered_fm).unwrap_or_default();
98 return Ok(format!("---\n{}---\n\n{}", yaml, rendered_body));
99 }
100 }
101
102 Ok(rendered_body)
103}
104
105fn render_frontmatter_values(
107 fields: &HashMap<String, Value>,
108 ctx: &RenderContext,
109) -> Result<HashMap<String, Value>, TemplateRenderError> {
110 let mut rendered = HashMap::new();
111 for (key, value) in fields {
112 let rendered_value = render_yaml_value(value, ctx)?;
113 rendered.insert(key.clone(), rendered_value);
114 }
115 Ok(rendered)
116}
117
118fn render_yaml_value(
120 value: &Value,
121 ctx: &RenderContext,
122) -> Result<Value, TemplateRenderError> {
123 match value {
124 Value::String(s) => {
125 let rendered = render_string(s, ctx)?;
126 Ok(Value::String(rendered))
127 }
128 Value::Sequence(seq) => {
129 let rendered: Result<Vec<Value>, _> =
130 seq.iter().map(|v| render_yaml_value(v, ctx)).collect();
131 Ok(Value::Sequence(rendered?))
132 }
133 Value::Mapping(map) => {
134 let mut rendered_map = serde_yaml::Mapping::new();
135 for (k, v) in map {
136 let rendered_v = render_yaml_value(v, ctx)?;
137 rendered_map.insert(k.clone(), rendered_v);
138 }
139 Ok(Value::Mapping(rendered_map))
140 }
141 _ => Ok(value.clone()),
143 }
144}
145
146pub fn render_string(
153 template: &str,
154 ctx: &RenderContext,
155) -> Result<String, TemplateRenderError> {
156 let re = Regex::new(r"\{\{([^{}]+)\}\}")
159 .map_err(|e| TemplateRenderError::Regex(e.to_string()))?;
160
161 let result = re.replace_all(template, |caps: ®ex::Captures<'_>| {
162 let expr = caps[1].trim();
163
164 if is_date_expr(expr)
166 && let Ok(parsed) = parse_date_expr(expr)
167 {
168 return evaluate_date_expr(&parsed);
169 }
170
171 if let Some((var_name, filter)) = parse_filter_expr(expr) {
173 if let Some(value) = ctx.get(var_name) {
174 return apply_filter(value, filter);
175 }
176 return caps[0].to_string();
178 }
179
180 ctx.get(expr).cloned().unwrap_or_else(|| caps[0].to_string())
182 });
183
184 Ok(result.into_owned())
185}
186
187fn parse_filter_expr(expr: &str) -> Option<(&str, &str)> {
190 if is_date_expr(expr) {
192 return None;
193 }
194
195 let parts: Vec<&str> = expr.splitn(2, '|').collect();
196 if parts.len() == 2 {
197 let var_name = parts[0].trim();
198 let filter = parts[1].trim();
199 if !var_name.is_empty() && !filter.is_empty() {
200 return Some((var_name, filter));
201 }
202 }
203 None
204}
205
206fn apply_filter(value: &str, filter: &str) -> String {
208 match filter {
209 "slugify" => slugify(value),
210 "lowercase" | "lower" => value.to_lowercase(),
211 "uppercase" | "upper" => value.to_uppercase(),
212 "trim" => value.trim().to_string(),
213 _ => value.to_string(), }
215}
216
217fn slugify(s: &str) -> String {
225 let mut result = String::with_capacity(s.len());
226
227 for c in s.chars() {
228 if c.is_ascii_alphanumeric() {
229 result.push(c.to_ascii_lowercase());
230 } else if c == ' ' || c == '_' || c == '-' {
231 if !result.ends_with('-') {
233 result.push('-');
234 }
235 }
236 }
238
239 result.trim_matches('-').to_string()
241}
242
243pub fn resolve_template_output_path(
248 template: &LoadedTemplate,
249 cfg: &ResolvedConfig,
250 ctx: &RenderContext,
251) -> Result<Option<PathBuf>, TemplateRenderError> {
252 if let Some(ref fm) = template.frontmatter
253 && let Some(ref output) = fm.output
254 {
255 let rendered = render_string(output, ctx)?;
256 let path = cfg.vault_root.join(&rendered);
257 return Ok(Some(path));
258 }
259 Ok(None)
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_slugify_basic() {
268 assert_eq!(slugify("Hello World"), "hello-world");
269 assert_eq!(slugify("Test Task"), "test-task");
270 }
271
272 #[test]
273 fn test_slugify_special_chars() {
274 assert_eq!(slugify("Hello, World!"), "hello-world");
275 assert_eq!(slugify("What's up?"), "whats-up");
276 assert_eq!(slugify("foo@bar.com"), "foobarcom");
277 }
278
279 #[test]
280 fn test_slugify_underscores() {
281 assert_eq!(slugify("hello_world"), "hello-world");
282 assert_eq!(slugify("foo_bar_baz"), "foo-bar-baz");
283 }
284
285 #[test]
286 fn test_slugify_multiple_spaces() {
287 assert_eq!(slugify("hello world"), "hello-world");
288 assert_eq!(slugify(" leading and trailing "), "leading-and-trailing");
289 }
290
291 #[test]
292 fn test_slugify_mixed() {
293 assert_eq!(slugify("My Task: Do Something!"), "my-task-do-something");
294 assert_eq!(slugify("2024-01-15 Meeting Notes"), "2024-01-15-meeting-notes");
295 }
296
297 #[test]
298 fn test_render_string_with_slugify_filter() {
299 let mut ctx = RenderContext::new();
300 ctx.insert("title".into(), "Hello World".into());
301
302 let result = render_string("{{title | slugify}}", &ctx).unwrap();
303 assert_eq!(result, "hello-world");
304 }
305
306 #[test]
307 fn test_render_string_with_lowercase_filter() {
308 let mut ctx = RenderContext::new();
309 ctx.insert("name".into(), "HELLO".into());
310
311 let result = render_string("{{name | lowercase}}", &ctx).unwrap();
312 assert_eq!(result, "hello");
313
314 let result = render_string("{{name | lower}}", &ctx).unwrap();
315 assert_eq!(result, "hello");
316 }
317
318 #[test]
319 fn test_render_string_with_uppercase_filter() {
320 let mut ctx = RenderContext::new();
321 ctx.insert("name".into(), "hello".into());
322
323 let result = render_string("{{name | uppercase}}", &ctx).unwrap();
324 assert_eq!(result, "HELLO");
325 }
326
327 #[test]
328 fn test_render_string_filter_in_path() {
329 let mut ctx = RenderContext::new();
330 ctx.insert("vault_root".into(), "/vault".into());
331 ctx.insert("title".into(), "My New Task".into());
332
333 let result =
334 render_string("{{vault_root}}/tasks/{{title | slugify}}.md", &ctx).unwrap();
335 assert_eq!(result, "/vault/tasks/my-new-task.md");
336 }
337
338 #[test]
339 fn test_render_string_unknown_filter() {
340 let mut ctx = RenderContext::new();
341 ctx.insert("name".into(), "hello".into());
342
343 let result = render_string("{{name | unknown}}", &ctx).unwrap();
345 assert_eq!(result, "hello");
346 }
347
348 #[test]
349 fn test_render_string_missing_var_with_filter() {
350 let ctx = RenderContext::new();
351
352 let result = render_string("{{missing | slugify}}", &ctx).unwrap();
354 assert_eq!(result, "{{missing | slugify}}");
355 }
356
357 #[test]
358 fn test_date_format_not_parsed_as_filter() {
359 let ctx = RenderContext::new();
360
361 let result = render_string("{{today | %Y-%m-%d}}", &ctx).unwrap();
363 assert!(result.contains('-'));
365 assert!(!result.contains("today"));
366 }
367}