zettel_core/template.rs
1// crates/zettel-core/src/template.rs - Template System Core Logic
2//
3// This module implements the template system that allows users to customize
4// note creation beyond the built-in "# Title\n\nBacklink" format.
5//
6// DESIGN PRINCIPLES:
7// - Pure functions: No I/O, only string processing
8// - Composable: Functions can be used independently
9// - Fail-fast: Template validation catches errors early
10// - Flexible: Support both single templates and template directories
11//
12// TEMPLATE FORMAT:
13// Templates are markdown files with special placeholders:
14// - {{title}} - Replaced with user-provided note title
15// - {{link}} - Replaced with backlink to parent note
16//
17// EXAMPLE TEMPLATE:
18// ```markdown
19// # {{title}}
20//
21// Created: {{date}}
22// Parent: {{link}}
23//
24// ## Notes
25//
26// ## References
27// ```
28
29use regex::Regex;
30use std::collections::HashMap;
31use thiserror::Error;
32
33use crate::config::TemplateConfig;
34
35/// Errors that can occur during template operations
36#[derive(Error, Debug, Clone, PartialEq)]
37pub enum TemplateError {
38 #[error("Template validation failed: {0}")]
39 ValidationError(String),
40
41 #[error("Missing required placeholder: {0}")]
42 MissingPlaceholder(String),
43
44 #[error("Invalid template configuration: {0}")]
45 ConfigError(String),
46
47 #[error("Template processing error: {0}")]
48 ProcessingError(String),
49}
50
51/// Result type for template operations
52pub type TemplateResult<T> = Result<T, TemplateError>;
53
54/// Represents validation result for template content
55#[derive(Debug, Clone, PartialEq)]
56pub struct ValidationResult {
57 /// Whether the template passed validation
58 pub valid: bool,
59 /// Error message if validation failed
60 pub message: Option<String>,
61 /// List of missing required placeholders
62 pub missing_placeholders: Vec<String>,
63 /// List of found placeholders
64 pub found_placeholders: Vec<String>,
65}
66
67impl ValidationResult {
68 /// Create a successful validation result
69 pub fn success(found_placeholders: Vec<String>) -> Self {
70 Self {
71 valid: true,
72 message: None,
73 missing_placeholders: Vec::new(),
74 found_placeholders,
75 }
76 }
77
78 /// Create a failed validation result
79 pub fn failure(message: String, missing: Vec<String>, found: Vec<String>) -> Self {
80 Self {
81 valid: false,
82 message: Some(message),
83 missing_placeholders: missing,
84 found_placeholders: found,
85 }
86 }
87}
88
89/// Core template processing service
90///
91/// This handles all template-related business logic:
92/// - Validation of template content against requirements
93/// - Placeholder substitution with actual values
94/// - Content generation for both template and built-in modes
95///
96/// PURE FUNCTIONS DESIGN:
97/// All methods are pure functions that take input and produce output
98/// without side effects. This makes testing easy and behavior predictable.
99pub struct TemplateService;
100
101impl TemplateService {
102 /// Determines if templates should be used based on configuration
103 ///
104 /// Business rule: Templates are used when:
105 /// 1. Template system is enabled in config
106 /// 2. Template file path is specified and non-empty
107 ///
108 /// This is a pure function that only examines configuration.
109 pub fn should_use_template(config: &TemplateConfig) -> bool {
110 config.enabled && !config.file.trim().is_empty()
111 }
112
113 /// Validates template content against configuration requirements
114 ///
115 /// Checks that required placeholders are present. This catches configuration
116 /// errors early, before note creation fails.
117 ///
118 /// VALIDATION RULES:
119 /// - If require_title is true, {{title}} must be present
120 /// - If require_link is true, {{link}} must be present
121 /// - Unknown placeholders are allowed (forward compatibility)
122 ///
123 /// EXAMPLES:
124 /// ```rust
125 /// let config = TemplateConfig { require_title: true, require_link: true, .. };
126 /// let content = "# {{title}}\n\nParent: {{link}}";
127 /// let result = TemplateService::validate_template(content, &config);
128 /// assert!(result.valid);
129 /// ```
130 pub fn validate_template(content: &str, config: &TemplateConfig) -> ValidationResult {
131 // Extract all placeholders from template content
132 let found_placeholders = Self::extract_placeholders(content);
133 let mut missing_placeholders = Vec::new();
134
135 // Check for required title placeholder
136 if config.require_title && !found_placeholders.contains(&"title".to_string()) {
137 missing_placeholders.push("title".to_string());
138 }
139
140 // Check for required link placeholder
141 if config.require_link && !found_placeholders.contains(&"link".to_string()) {
142 missing_placeholders.push("link".to_string());
143 }
144
145 // Generate validation result
146 if missing_placeholders.is_empty() {
147 ValidationResult::success(found_placeholders)
148 } else {
149 let message = format!(
150 "Template missing required placeholder(s): {{{{{}}}}}",
151 missing_placeholders.join("}}, {{")
152 );
153 ValidationResult::failure(message, missing_placeholders, found_placeholders)
154 }
155 }
156
157 /// Extracts all placeholder names from template content
158 ///
159 /// Finds all instances of {{placeholder_name}} and returns the names.
160 /// This is used for validation and debugging.
161 ///
162 /// REGEX PATTERN: {{(\w+)}}
163 /// - {{ and }} are literal braces
164 /// - (\w+) captures word characters (letters, numbers, underscore)
165 ///
166 /// EXAMPLES:
167 /// - "# {{title}}" -> ["title"]
168 /// - "{{title}} and {{link}}" -> ["title", "link"]
169 /// - "{{title}} {{title}}" -> ["title"] (deduplicated)
170 fn extract_placeholders(content: &str) -> Vec<String> {
171 let placeholder_regex = Regex::new(r"\{\{(\w+)\}\}").unwrap();
172 let mut placeholders = Vec::new();
173
174 for capture in placeholder_regex.captures_iter(content) {
175 if let Some(placeholder) = capture.get(1) {
176 let name = placeholder.as_str().to_string();
177 if !placeholders.contains(&name) {
178 placeholders.push(name);
179 }
180 }
181 }
182
183 placeholders
184 }
185
186 /// Generates final note content using template or built-in format
187 ///
188 /// This is the core content generation function. It handles both template
189 /// mode (with placeholder substitution) and built-in mode (standard format).
190 ///
191 /// TEMPLATE MODE:
192 /// Replaces all placeholders with provided values. Unknown placeholders
193 /// are left unchanged for forward compatibility.
194 ///
195 /// BUILT-IN MODE:
196 /// Creates standard "# Title\n\nBacklink" format when no template provided.
197 ///
198 /// BUSINESS RULES:
199 /// - Empty title is handled gracefully
200 /// - Empty backlink is handled gracefully
201 /// - Whitespace is preserved from template
202 /// - Multiple occurrences of same placeholder are all replaced
203 ///
204 /// EXAMPLES:
205 /// ```rust
206 /// // Template mode
207 /// let template = "# {{title}}\n\nParent: {{link}}";
208 /// let content = TemplateService::generate_content(
209 /// Some(template), "My Note", "[[parent]]"
210 /// );
211 /// // Result: "# My Note\n\nParent: [[parent]]"
212 ///
213 /// // Built-in mode
214 /// let content = TemplateService::generate_content(
215 /// None, "My Note", "[[parent]]"
216 /// );
217 /// // Result: "# My Note\n\n[[parent]]"
218 /// ```
219 pub fn generate_content(
220 template_content: Option<&str>,
221 title: &str,
222 backlink_content: &str,
223 ) -> String {
224 match template_content {
225 Some(template) => {
226 // Template mode: substitute placeholders
227 Self::substitute_placeholders(template, title, backlink_content)
228 }
229 None => {
230 // Built-in mode: standard markdown format
231 Self::generate_builtin_content(title, backlink_content)
232 }
233 }
234 }
235
236 /// Substitutes placeholders in template with actual values
237 ///
238 /// Replaces known placeholders and leaves unknown ones unchanged.
239 /// This enables forward compatibility with future placeholder types.
240 ///
241 /// CURRENT PLACEHOLDERS:
242 /// - {{title}} -> note title
243 /// - {{link}} -> backlink content
244 ///
245 /// FUTURE PLACEHOLDERS (examples):
246 /// - {{date}} -> current date
247 /// - {{author}} -> note author
248 /// - {{id}} -> note ID
249 /// - {{parent_id}} -> parent note ID
250 fn substitute_placeholders(template: &str, title: &str, backlink: &str) -> String {
251 template
252 .replace("{{title}}", title)
253 .replace("{{link}}", backlink)
254 // Note: Unknown placeholders like {{date}} are left unchanged
255 // This allows templates to include future features
256 }
257
258 /// Generates built-in content format when no template is used
259 ///
260 /// Creates the standard zettelkasten note format:
261 /// - Heading with note title
262 /// - Blank line for separation
263 /// - Backlink to parent (if provided)
264 ///
265 /// FORMATTING RULES:
266 /// - Title becomes "# Title" heading
267 /// - Leading whitespace is trimmed from title
268 /// - Backlink gets separated by blank line
269 /// - Empty title or backlink are handled gracefully
270 fn generate_builtin_content(title: &str, backlink: &str) -> String {
271 let mut content = String::new();
272
273 // Add title heading if provided
274 if !title.trim().is_empty() {
275 content.push_str(&format!("# {}", title.trim_start()));
276 }
277
278 // Add backlink if provided
279 if !backlink.trim().is_empty() {
280 if !content.is_empty() {
281 content.push_str("\n\n");
282 }
283 content.push_str(backlink);
284 }
285
286 content
287 }
288
289 /// Resolves template file path based on configuration
290 ///
291 /// Handles both single template file and template directory modes.
292 /// Returns the final template file path to read.
293 ///
294 /// RESOLUTION LOGIC:
295 /// 1. If specific file is configured, use that
296 /// 2. If directory is configured, use default template within directory
297 /// 3. Validate configuration makes sense
298 ///
299 /// This is pure logic - actual file reading is handled by CLI layer.
300 pub fn resolve_template_path(config: &TemplateConfig) -> TemplateResult<String> {
301 if !config.file.trim().is_empty() {
302 // Direct file path specified
303 Ok(config.file.trim().to_string())
304 } else if !config.directory.trim().is_empty() {
305 // Template directory specified - use default template
306 if config.default_template.trim().is_empty() {
307 return Err(TemplateError::ConfigError(
308 "Template directory specified but no default template name provided"
309 .to_string(),
310 ));
311 }
312
313 let directory = config.directory.trim();
314 let template_name = config.default_template.trim();
315 Ok(format!("{}/{}", directory, template_name))
316 } else {
317 Err(TemplateError::ConfigError(
318 "No template file or directory specified".to_string(),
319 ))
320 }
321 }
322
323 /// Creates a context map for advanced template processing (future feature)
324 ///
325 /// This prepares for more sophisticated template systems that might use
326 /// templating engines like Handlebars or Tera. Currently returns basic
327 /// key-value pairs for the placeholders we support.
328 ///
329 /// FUTURE ENHANCEMENT:
330 /// Could support conditional logic, loops, includes, etc.
331 ///
332 /// ```handlebars
333 /// # {{title}}
334 /// {{#if parent}}
335 /// Parent: {{parent}}
336 /// {{/if}}
337 /// ```
338 #[allow(dead_code)]
339 pub fn create_template_context(title: &str, backlink: &str) -> HashMap<String, String> {
340 let mut context = HashMap::new();
341 context.insert("title".to_string(), title.to_string());
342 context.insert("link".to_string(), backlink.to_string());
343
344 // Future: Add more context variables
345 // context.insert("date".to_string(), chrono::Utc::now().format("%Y-%m-%d").to_string());
346 // context.insert("author".to_string(), get_author_from_config());
347 // context.insert("id".to_string(), note_id.to_string());
348
349 context
350 }
351}
352
353/// Template manager for handling multiple templates (future feature)
354///
355/// This would enable template selection during note creation:
356/// - Academic paper template
357/// - Daily note template
358/// - Meeting notes template
359/// - Reference note template
360///
361/// For now, it's a placeholder for future expansion.
362#[allow(dead_code)]
363pub struct TemplateManager {
364 templates: HashMap<String, String>,
365}
366
367#[allow(dead_code)]
368impl TemplateManager {
369 /// Create new template manager
370 pub fn new() -> Self {
371 Self {
372 templates: HashMap::new(),
373 }
374 }
375
376 /// Register a template with a name
377 pub fn register_template(&mut self, name: String, content: String) {
378 self.templates.insert(name, content);
379 }
380
381 /// Get template by name
382 pub fn get_template(&self, name: &str) -> Option<&String> {
383 self.templates.get(name)
384 }
385
386 /// List available template names
387 pub fn list_templates(&self) -> Vec<&String> {
388 self.templates.keys().collect()
389 }
390}
391
392// TEMPLATE SYSTEM BENEFITS:
393//
394// 1. PURE FUNCTIONS:
395// All template logic is pure - no side effects, easy to test
396// Functions take input and produce output predictably
397//
398// 2. COMPOSABILITY:
399// Template validation, substitution, and generation are separate
400// Can be used independently or composed together
401//
402// 3. EXTENSIBILITY:
403// Easy to add new placeholders or template features
404// Forward compatible - unknown placeholders are preserved
405//
406// 4. ERROR HANDLING:
407// Template validation catches configuration errors early
408// Clear error messages help users fix template issues
409//
410// 5. CONFIGURATION DRIVEN:
411// All behavior controlled by configuration
412// No hardcoded assumptions about template format
413//
414// EXAMPLES OF TEMPLATE USAGE:
415//
416// Basic template:
417// ```markdown
418// # {{title}}
419//
420// {{link}}
421//
422// ## Notes
423//
424// ## References
425// ```
426//
427// Academic template:
428// ```markdown
429// # {{title}}
430//
431// **Source:** {{link}}
432//
433// ## Summary
434//
435// ## Key Points
436//
437// ## Questions
438//
439// ## Related Ideas
440// ```
441//
442// Meeting notes template:
443// ```markdown
444// # Meeting: {{title}}
445//
446// **Previous:** {{link}}
447//
448// ## Agenda
449//
450// ## Notes
451//
452// ## Action Items
453//
454// ## Next Steps
455// ```
456//
457// UNIX COMPOSABILITY:
458//
459// The template system maintains Unix philosophy:
460//
461// ```bash
462// # Generate template content
463// echo "# {{title}}\n\n{{link}}" | zettel template apply "My Note" "[[parent]]"
464//
465// # Validate template
466// zettel template validate < my-template.md
467//
468// # List available placeholders
469// zettel template placeholders < my-template.md
470//
471// # Create note with template
472// zettel note create 1a "My Note" --template academic.md
473// ```