Skip to main content

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// ```