lib/render/
template.rs

1//! Defines types to represent a template's content and metadata.
2
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7use crate::result::{Error, Result};
8
9use super::defaults::{CONFIG_TAG_CLOSE, CONFIG_TAG_OPEN};
10use super::names::Names;
11
12/// A struct representing a fully configured template.
13#[derive(Clone, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub struct Template {
16    /// The template's id.
17    ///
18    /// This is the file path relative to the templates directory. It serves to identify a template
19    /// within the registry when rendering.
20    ///
21    /// ```plaintext
22    /// --> /path/to/templates/nested/template.md
23    /// -->                    nested/template.md
24    /// ```
25    #[serde(skip_deserializing)]
26    pub id: String,
27
28    /// The unparsed contents of the template.
29    ///
30    /// This gets parsed and validated during template registration.
31    #[serde(skip_deserializing)]
32    pub contents: String,
33
34    /// The template's group name.
35    ///
36    /// See [`StructureMode::FlatGrouped`] and [`StructureMode::NestedGrouped`] for more information.
37    #[serde(deserialize_with = "crate::utils::deserialize_and_sanitize")]
38    pub group: String,
39
40    /// The template's context mode i.e what the template intends to render.
41    ///
42    /// See [`ContextMode`] for more information.
43    #[serde(rename = "context")]
44    pub context_mode: ContextMode,
45
46    /// The template's structure mode i.e. how the output should be structured.
47    ///
48    /// See [`StructureMode`] for more information.
49    #[serde(rename = "structure")]
50    pub structure_mode: StructureMode,
51
52    /// The template's file extension.
53    pub extension: String,
54
55    /// The template strings for generating output file and directory names.
56    #[serde(default)]
57    pub names: Names,
58}
59
60impl Template {
61    /// Creates a new instance of [`Template`].
62    ///
63    /// # Arguments
64    ///
65    /// * `path` - The path to the template relative to the templates directory.
66    /// * `string` - The contents of the template file.
67    ///
68    /// # Errors
69    ///
70    /// Will return `Err` if:
71    /// * The template's opening and closing config tags have syntax errors.
72    /// * The tempalte's config has syntax errors or is missing required fields.
73    pub fn new<P>(path: P, string: &str) -> Result<Self>
74    where
75        P: AsRef<Path>,
76    {
77        let path = path.as_ref();
78
79        let (config, contents) = Self::parse(string).ok_or(Error::InvalidTemplateConfig {
80            path: path.display().to_string(),
81        })?;
82
83        let mut template: Self = serde_yaml_ng::from_str(config)?;
84
85        template.id = path.display().to_string();
86        template.contents = contents;
87
88        Ok(template)
89    }
90
91    /// Returns a tuple containing the template's configuration and its contents respectively.
92    ///
93    /// Returns `None` if the template's config block is formatted incorrectly.
94    fn parse(string: &str) -> Option<(&str, String)> {
95        // Find where the opening tag starts...
96        let mut config_start = string.find(CONFIG_TAG_OPEN)?;
97
98        // (Save the pre-config contents.)
99        let pre_config_contents = &string[0..config_start];
100
101        // ...and offset it by the length of the config opening tag.
102        config_start += CONFIG_TAG_OPEN.len();
103
104        // Starting from where we found the opening tag, search for a closing tag. If we don't offset
105        // the starting point we might find another closing tag located before the opening tag.
106        let mut config_end = string[config_start..].find(CONFIG_TAG_CLOSE)?;
107        // Remove the offset we just used.
108        config_end += config_start;
109
110        let config = &string[config_start..config_end];
111
112        // The template's post-config contents start after the closiong tag.
113        let post_config_contents = config_end + CONFIG_TAG_CLOSE.len();
114        let mut post_config_contents = &string[post_config_contents..];
115
116        // Trim a single linebreak if its present.
117        if post_config_contents.starts_with('\n') {
118            post_config_contents = &post_config_contents[1..];
119        }
120
121        let contents = format!("{pre_config_contents}{post_config_contents}",);
122
123        Some((config, contents))
124    }
125}
126
127impl std::fmt::Debug for Template {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.debug_struct("Template")
130            .field("id", &self.id)
131            .field("group", &self.group)
132            .field("context_mode", &self.context_mode)
133            .field("structure_mode", &self.structure_mode)
134            .finish_non_exhaustive()
135    }
136}
137
138/// A struct representing an unconfigured partial template.
139///
140/// Partial templates get their configuration from the normal templates that `include` them.
141#[derive(Clone)]
142pub struct TemplatePartial {
143    /// The template's id.
144    ///
145    /// This is the file path relative to the templates directory. It serves to identify a partial
146    /// template when called in an `include` tag from within a non-partial template.
147    ///
148    /// ```plaintext
149    /// --> /path/to/templates/nested/template.md
150    /// -->                    nested/template.md
151    /// --> {% include "nested/template.md" %}
152    /// ````
153    pub id: String,
154
155    /// The unparsed contents of the template.
156    ///
157    /// This gets parsed and validated *only* if its called in an `include` tag in a non-partial
158    /// template that is being registered/parsed/valiated.
159    pub contents: String,
160}
161
162impl TemplatePartial {
163    /// Creates a new instance of [`TemplatePartial`].
164    ///
165    /// # Arguments
166    ///
167    /// * `path` - The path to the template relative to the templates directory.
168    /// * `string` - The contents of the template file.
169    pub fn new<P>(path: P, string: &str) -> Self
170    where
171        P: AsRef<Path>,
172    {
173        Self {
174            id: path.as_ref().display().to_string(),
175            contents: string.to_owned(),
176        }
177    }
178}
179
180impl std::fmt::Debug for TemplatePartial {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("TemplatePartial")
183            .field("id", &self.id)
184            .finish_non_exhaustive()
185    }
186}
187
188/// A struct representing a rendered template.
189#[derive(Default)]
190pub struct Render {
191    /// The path to where the template will be written to.
192    ///
193    /// This path should be relative to the final output directory as this path is appended to it to
194    /// determine the the full output path.
195    pub path: PathBuf,
196
197    /// The final output filename.
198    pub filename: String,
199
200    /// The rendered content.
201    pub contents: String,
202}
203
204impl Render {
205    /// Creates a new instance of [`Template`].
206    #[must_use]
207    pub fn new(path: PathBuf, filename: String, contents: String) -> Self {
208        Self {
209            path,
210            filename,
211            contents,
212        }
213    }
214}
215
216impl std::fmt::Debug for Render {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("Render")
219            .field("path", &self.path)
220            .field("filename", &self.filename)
221            .finish_non_exhaustive()
222    }
223}
224
225/// An enum representing the ways to structure a template's rendered files.
226#[derive(Debug, Clone, Copy, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum StructureMode {
229    /// When selected, the template is rendered to the output directory without any structure.
230    ///
231    /// ```yaml
232    /// output-mode: flat
233    /// ```
234    ///
235    /// ```plaintext
236    /// [ouput-directory]
237    ///  │
238    ///  ├─ [template-name-01].[extension]
239    ///  ├─ [template-name-01].[extension]
240    ///  └─ ...
241    /// ```
242    Flat,
243
244    /// When selected, the template is rendered to the output directory and placed inside a
245    /// directory named after its `group`. This useful if there are multiple related and unrelated
246    /// templates being rendered to the same directory.
247    ///
248    /// ```yaml
249    /// output-mode: flat-grouped
250    /// ```
251    ///
252    /// ```plaintext
253    /// [ouput-directory]
254    ///  │
255    ///  ├─ [template-group-01]
256    ///  │   ├─ [template-name-01].[extension]
257    ///  │   ├─ [template-name-01].[extension]
258    ///  │   └─ ...
259    ///  │
260    ///  ├─ [template-group-02]
261    ///  │   └─ ...
262    ///  └─ ...
263    /// ```
264    FlatGrouped,
265
266    /// When selected, the template is rendered to the output directory and placed inside a
267    /// directory named after its `nested-directory-template`. This useful if multiple templates are
268    /// used to represent a single book i.e. a book template used to render a book's information to
269    /// a single file and an annotation template used to render each annotation to a separate file.
270    ///
271    /// ```yaml
272    /// output-mode: nested
273    /// ```
274    ///
275    /// ```plaintext
276    /// [ouput-directory]
277    ///  │
278    ///  ├─ [author-title-01]
279    ///  │   ├─ [template-name-01].[extension]
280    ///  │   ├─ [template-name-01].[extension]
281    ///  │   └─ ...
282    ///  │
283    ///  ├─ [author-title-02]
284    ///  │   └─ ...
285    ///  └─ ...
286    /// ```
287    Nested,
288
289    /// When selected, the template is rendered to the output directory and placed inside a
290    /// directory named after its `group` and another named after its `nested-directory-template`.
291    /// This useful if multiple templates are used to represent a single book i.e. a book template
292    /// and an annotation template and there are multiple related and unrelated templates being
293    /// rendered to the same directory.
294    ///
295    ///
296    /// ```yaml
297    /// output-mode: nested-grouped
298    /// ```
299    ///
300    /// ```plaintext
301    /// [ouput-directory]
302    ///  │
303    ///  ├─ [template-group-01]
304    ///  │   │
305    ///  │   ├─ [author-title-01]
306    ///  │   │   ├─ [template-name-01].[extension]
307    ///  │   │   ├─ [template-name-01].[extension]
308    ///  │   │   └─ ...
309    ///  │   │
310    ///  │   ├─ [author-title-02]
311    ///  │   │   ├─ [template-name-02].[extension]
312    ///  │   │   ├─ [template-name-02].[extension]
313    ///  │   │   └─ ...
314    ///  │   └─ ...
315    ///  │
316    ///  ├─ [template-group-02]
317    ///  │   ├─ [author-title-01]
318    ///  │   │   └─ ...
319    ///  │   └─ ...
320    ///  └─ ...
321    /// ```
322    NestedGrouped,
323}
324
325/// An enum representing what a template intends to render.
326#[derive(Debug, Clone, Copy, Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum ContextMode {
329    /// When selected, the template is rendered to a single file containing a [`Book`][book] and all
330    /// its [`Annotation`][annotation]s.
331    ///
332    /// ```yaml
333    /// render-context: book
334    /// ```
335    ///
336    /// ```plaintext
337    /// [ouput-directory]
338    ///  └─ [template-name].[extension]
339    /// ```
340    ///
341    /// [book]: crate::models::book::Book
342    /// [annotation]: crate::models::annotation::Annotation
343    Book,
344
345    /// When selected, the template is rendered to multiple files containing a [`Book`][book] and
346    /// only one its [`Annotation`][annotation]s.
347    ///
348    /// ```yaml
349    /// render-context: annotation
350    /// ```
351    ///
352    /// ```plaintext
353    /// [ouput-directory]
354    ///  ├─ [template-name].[extension]
355    ///  ├─ [template-name].[extension]
356    ///  ├─ [template-name].[extension]
357    ///  └─ ...
358    /// ```
359    ///
360    /// [book]: crate::models::book::Book
361    /// [annotation]: crate::models::annotation::Annotation
362    Annotation,
363}
364
365#[cfg(test)]
366mod test {
367
368    use super::*;
369
370    use crate::defaults::test::TemplatesDirectory;
371    use crate::utils;
372
373    mod invalid_config {
374
375        use super::*;
376
377        // Tests that a missing config block returns an error.
378        #[test]
379        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
380        fn missing_config() {
381            let template = utils::testing::load_template_str(
382                TemplatesDirectory::InvalidConfig,
383                "missing-config.txt",
384            );
385            Template::parse(&template).unwrap();
386        }
387
388        // Tests that a missing closing tag returns an error.
389        #[test]
390        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
391        fn missing_closing_tag() {
392            let template = utils::testing::load_template_str(
393                TemplatesDirectory::InvalidConfig,
394                "missing-closing-tag.txt",
395            );
396            Template::parse(&template).unwrap();
397        }
398
399        // Tests that missing `readstor` in the opening tag returns an error.
400        #[test]
401        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
402        fn incomplete_opening_tag_01() {
403            let template = utils::testing::load_template_str(
404                TemplatesDirectory::InvalidConfig,
405                "incomplete-opening-tag-01.txt",
406            );
407            Template::parse(&template).unwrap();
408        }
409
410        // Tests that missing the `!` in the opening tag returns an error.
411        #[test]
412        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
413        fn incomplete_opening_tag_02() {
414            let template = utils::testing::load_template_str(
415                TemplatesDirectory::InvalidConfig,
416                "incomplete-opening-tag-02.txt",
417            );
418            Template::parse(&template).unwrap();
419        }
420
421        // Tests that no linebreak after `readstor` returns an error.
422        #[test]
423        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
424        fn missing_linebreak_01() {
425            let template = utils::testing::load_template_str(
426                TemplatesDirectory::InvalidConfig,
427                "missing-linebreak-01.txt",
428            );
429            Template::parse(&template).unwrap();
430        }
431
432        // Tests that no linebreak after the config body returns an error.
433        #[test]
434        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
435        fn missing_linebreak_02() {
436            let template = utils::testing::load_template_str(
437                TemplatesDirectory::InvalidConfig,
438                "missing-linebreak-02.txt",
439            );
440            Template::parse(&template).unwrap();
441        }
442
443        // Tests that no linebreak after the closing tag returns an error.
444        #[test]
445        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
446        fn missing_linebreak_03() {
447            let template = utils::testing::load_template_str(
448                TemplatesDirectory::InvalidConfig,
449                "missing-linebreak-03.txt",
450            );
451            Template::parse(&template).unwrap();
452        }
453
454        // Tests that no linebreak before the opening tag returns an error.
455        #[test]
456        #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
457        fn missing_linebreak_04() {
458            let template = utils::testing::load_template_str(
459                TemplatesDirectory::InvalidConfig,
460                "missing-linebreak-04.txt",
461            );
462            Template::parse(&template).unwrap();
463        }
464    }
465
466    mod valid_config {
467
468        use super::*;
469
470        // Test the minimum required keys.
471        #[test]
472        fn minimum_required_keys() {
473            let filename = "minimum-required-keys.txt";
474            let template =
475                utils::testing::load_template_str(TemplatesDirectory::ValidConfig, filename);
476            Template::new(filename, &template).unwrap();
477        }
478
479        // Tests that a template with pre- and post-config-content returns no error.
480        #[test]
481        fn pre_and_post_config_content() {
482            let template = utils::testing::load_template_str(
483                TemplatesDirectory::ValidConfig,
484                "pre-and-post-config-content.txt",
485            );
486            Template::parse(&template).unwrap();
487        }
488
489        // Tests that a template with pre-config-content returns no error.
490        #[test]
491        fn pre_config_content() {
492            let template = utils::testing::load_template_str(
493                TemplatesDirectory::ValidConfig,
494                "pre-config-content.txt",
495            );
496            Template::parse(&template).unwrap();
497        }
498
499        // Tests that a template with post-config-content returns no error.
500        #[test]
501        fn post_config_content() {
502            let template = utils::testing::load_template_str(
503                TemplatesDirectory::ValidConfig,
504                "post-config-content.txt",
505            );
506            Template::parse(&template).unwrap();
507        }
508    }
509}