Skip to main content

frontmatter_gen/
engine.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Site Generation Engine
5//!
6//! This module provides the core site generation functionality for the Static Site Generator.
7//! It is only available when the `ssg` feature is enabled.
8//!
9//! ## Features
10//!
11//! - Asynchronous file processing
12//! - Content caching with size limits
13//! - Safe template rendering
14//! - Secure asset processing
15//! - Comprehensive metadata handling
16//! - Error recovery strategies
17//!
18//! ## Example
19//!
20//! ```rust,no_run
21//! # #[cfg(feature = "ssg")]
22//! # async fn example() -> anyhow::Result<()> {
23//! use frontmatter_gen::config::Config;
24//! use frontmatter_gen::engine::Engine;
25//!
26//! let config = Config::builder()
27//!     .site_name("My Blog")
28//!     .content_dir("content")
29//!     .template_dir("templates")
30//!     .output_dir("output")
31//!     .build()?;
32//!
33//! let engine = Engine::new()?;
34//! engine.generate(&config).await?;
35//!
36//! # Ok(())
37//! # }
38//! ```
39
40#[cfg(feature = "ssg")]
41use crate::config::Config;
42#[cfg(feature = "ssg")]
43use anyhow::{Context, Result};
44#[cfg(feature = "ssg")]
45use pulldown_cmark::{html, Parser};
46#[cfg(feature = "ssg")]
47use std::collections::HashMap;
48#[cfg(feature = "ssg")]
49use std::path::{Path, PathBuf};
50#[cfg(feature = "ssg")]
51use std::sync::Arc;
52#[cfg(feature = "ssg")]
53use tera::{Context as TeraContext, Tera};
54#[cfg(feature = "ssg")]
55use tokio::{fs, sync::RwLock};
56
57#[cfg(feature = "ssg")]
58/// Maximum number of items to store in caches.
59const MAX_CACHE_SIZE: usize = 1000;
60
61#[cfg(feature = "ssg")]
62/// A size-limited cache for storing key-value pairs.
63///
64/// Ensures the cache does not exceed the defined `max_size`. When the limit
65/// is reached, the oldest entry is evicted to make room for new items.
66#[derive(Debug)]
67struct SizeCache<K, V> {
68    items: HashMap<K, V>,
69    max_size: usize,
70}
71
72#[cfg(feature = "ssg")]
73impl<K: Eq + std::hash::Hash + Clone, V> SizeCache<K, V> {
74    fn new(max_size: usize) -> Self {
75        Self {
76            items: HashMap::with_capacity(max_size),
77            max_size,
78        }
79    }
80
81    fn insert(&mut self, key: K, value: V) -> Option<V> {
82        // Only make room when the key is genuinely new. Evicting before
83        // checking meant that replacing an existing value at capacity
84        // dropped an unrelated entry — and, since the victim is
85        // whichever key the map iterates first, sometimes dropped the
86        // very entry being replaced, so `insert` returned `None` for a
87        // key that was present a moment earlier.
88        if !self.items.contains_key(&key)
89            && self.items.len() >= self.max_size
90        {
91            if let Some(old_key) = self.items.keys().next().cloned() {
92                let _ = self.items.remove(&old_key);
93            }
94        }
95        self.items.insert(key, value)
96    }
97
98    fn clear(&mut self) {
99        self.items.clear();
100    }
101}
102
103#[cfg(feature = "ssg")]
104/// Represents a processed content file, including its metadata and content body.
105#[derive(Debug)]
106pub struct ContentFile {
107    dest_path: PathBuf,
108    metadata: HashMap<String, serde_json::Value>,
109    content: String,
110}
111
112#[cfg(feature = "ssg")]
113/// The primary engine responsible for site generation.
114///
115/// Handles the loading of templates, processing of content files, rendering
116/// of pages, and copying of static assets.
117#[derive(Debug)]
118pub struct Engine {
119    content_cache: Arc<RwLock<SizeCache<PathBuf, ContentFile>>>,
120    template_cache: Arc<RwLock<SizeCache<String, String>>>,
121}
122
123#[cfg(feature = "ssg")]
124impl Engine {
125    /// Creates a new `Engine` instance.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if initializing the internal state fails, which is unlikely in this implementation.
130    pub fn new() -> Result<Self> {
131        log::debug!("Initializing SSG Engine");
132        Ok(Self {
133            content_cache: Arc::new(RwLock::new(SizeCache::new(
134                MAX_CACHE_SIZE,
135            ))),
136            template_cache: Arc::new(RwLock::new(SizeCache::new(
137                MAX_CACHE_SIZE,
138            ))),
139        })
140    }
141
142    /// Orchestrates the complete site generation process.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if:
147    /// - The output directory cannot be created.
148    /// - Templates fail to load.
149    /// - Content files fail to process.
150    /// - Pages fail to generate.
151    /// - Assets fail to copy.
152    pub async fn generate(&self, config: &Config) -> Result<()> {
153        log::info!("Starting site generation");
154
155        fs::create_dir_all(&config.output_dir)
156            .await
157            .context("Failed to create output directory")?;
158
159        self.load_templates(config).await?;
160        self.process_content_files(config).await?;
161        self.generate_pages(config).await?;
162        self.copy_assets(config).await?;
163
164        log::info!("Site generation completed successfully");
165
166        Ok(())
167    }
168
169    /// Loads and caches all templates from the template directory.
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if:
174    /// - Template files cannot be read or parsed.
175    /// - Directory entries fail to load.
176    /// - File paths contain invalid characters.
177    pub async fn load_templates(&self, config: &Config) -> Result<()> {
178        log::debug!(
179            "Loading templates from: {}",
180            config.template_dir.display()
181        );
182
183        let mut templates = self.template_cache.write().await;
184        templates.clear();
185
186        let mut entries = fs::read_dir(&config.template_dir).await?;
187        while let Some(entry) = entries.next_entry().await? {
188            let path = entry.path();
189            if path
190                .extension()
191                .is_some_and(|ext| ext == "html" || ext == "hbs")
192            {
193                let content = fs::read_to_string(&path).await.context(
194                    format!(
195                        "Failed to read template file: {}",
196                        path.display()
197                    ),
198                )?;
199
200                let name = path
201                    .file_stem()
202                    .unwrap_or_default()
203                    .to_string_lossy()
204                    .into_owned();
205                log::debug!("Loaded template: {}", name);
206                let _ = templates.insert(name, content);
207            }
208        }
209
210        drop(templates);
211
212        Ok(())
213    }
214
215    /// Processes all content files in the content directory.
216    ///
217    /// # Errors
218    ///
219    /// This function will return an error if:
220    /// - The content directory cannot be read.
221    /// - Any content file fails to process.
222    /// - Writing to the cache encounters an issue.
223    pub async fn process_content_files(
224        &self,
225        config: &Config,
226    ) -> Result<()> {
227        log::debug!(
228            "Processing content files from: {}",
229            config.content_dir.display()
230        );
231
232        let mut entries = fs::read_dir(&config.content_dir).await?;
233
234        while let Some(entry) = entries.next_entry().await? {
235            let path = entry.path();
236            if path.extension().is_some_and(|ext| ext == "md") {
237                let content =
238                    self.process_content_file(&path, config).await?;
239
240                // Scope the write lock for the cache
241                {
242                    let mut content_cache =
243                        self.content_cache.write().await;
244                    let _ = content_cache.insert(path.clone(), content);
245                }
246
247                log::debug!(
248                    "Processed content file: {}",
249                    path.display()
250                );
251            }
252        }
253
254        Ok(())
255    }
256
257    /// Processes a single content file and prepares it for rendering.
258    ///
259    /// # Errors
260    ///
261    /// This function will return an error if:
262    /// - The content file cannot be read.
263    /// - The front matter extraction fails.
264    /// - The Markdown to HTML conversion encounters an issue.
265    /// - The destination path is invalid.
266    pub async fn process_content_file(
267        &self,
268        path: &Path,
269        config: &Config,
270    ) -> Result<ContentFile> {
271        let raw_content = fs::read_to_string(path).await.context(
272            format!("Failed to read content file: {}", path.display()),
273        )?;
274
275        let (metadata, markdown_content) =
276            self.extract_front_matter(&raw_content)?;
277
278        // Convert Markdown to HTML
279        let parser = Parser::new(&markdown_content);
280        let mut html_content = String::new();
281        html::push_html(&mut html_content, parser);
282
283        let dest_path = config
284            .output_dir
285            .join(path.strip_prefix(&config.content_dir)?)
286            .with_extension("html");
287
288        Ok(ContentFile {
289            dest_path,
290            metadata,
291            content: html_content,
292        })
293    }
294
295    /// Extracts frontmatter metadata and content body from a file.
296    ///
297    /// # Errors
298    ///
299    /// This function will return an error if:
300    /// - The front matter is not valid YAML.
301    /// - The content cannot be split correctly into metadata and body.
302    pub fn extract_front_matter(
303        &self,
304        content: &str,
305    ) -> Result<(HashMap<String, serde_json::Value>, String)> {
306        let parts: Vec<&str> = content.splitn(3, "---").collect();
307        match parts.len() {
308            3 => {
309                let metadata = noyalib::from_str(parts[1])?;
310                Ok((metadata, parts[2].trim().to_string()))
311            }
312            _ => Ok((HashMap::new(), content.to_string())),
313        }
314    }
315
316    /// Renders a template with the provided content.
317    ///
318    /// # Errors
319    ///
320    /// This function will return an error if:
321    /// - The template contains invalid syntax.
322    /// - The rendering process fails due to missing or invalid context variables.
323    pub fn render_template(
324        &self,
325        template: &str,
326        content: &ContentFile,
327    ) -> Result<String> {
328        log::debug!(
329            "Rendering template for: {}",
330            content.dest_path.display()
331        );
332
333        let mut tera_context = TeraContext::new();
334        tera_context.insert("content", &content.content);
335
336        for (key, value) in &content.metadata {
337            // tera 2's `Context::insert` requires the key to be
338            // `Into<Cow<'static, str>>`; a borrowed `&String` isn't 'static,
339            // so hand it an owned `String`.
340            tera_context.insert(key.clone(), value);
341        }
342
343        let mut tera = Tera::default();
344        tera.add_raw_template("template", template)?;
345
346        tera.render("template", &tera_context).map_err(|e| {
347            anyhow::Error::msg(format!(
348                "Template rendering failed: {}",
349                e
350            ))
351        })
352    }
353
354    /// Copies static assets from the content directory to the output directory.
355    ///
356    /// # Errors
357    ///
358    /// This function will return an error if:
359    /// - The assets directory does not exist or cannot be read.
360    /// - A file or directory cannot be copied to the output directory.
361    /// - An I/O error occurs during the copying process.
362    pub async fn copy_assets(&self, config: &Config) -> Result<()> {
363        let assets_dir = config.content_dir.join("assets");
364        if assets_dir.exists() {
365            log::debug!(
366                "Copying assets from: {}",
367                assets_dir.display()
368            );
369
370            let dest_assets_dir = config.output_dir.join("assets");
371            if dest_assets_dir.exists() {
372                fs::remove_dir_all(&dest_assets_dir).await?;
373            }
374            fs::create_dir_all(&dest_assets_dir).await?;
375            Self::copy_dir_recursive(&assets_dir, &dest_assets_dir)
376                .await?;
377        }
378        Ok(())
379    }
380
381    /// Recursively copies a directory and its contents.
382    async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
383        // Ensure the destination directory exists.
384        fs::create_dir_all(dst).await?;
385
386        // Stack for directories to process.
387        let mut stack = vec![(src.to_path_buf(), dst.to_path_buf())];
388
389        while let Some((src_dir, dst_dir)) = stack.pop() {
390            // Read the source directory.
391            let mut entries = fs::read_dir(&src_dir).await?;
392
393            while let Some(entry) = entries.next_entry().await? {
394                let path = entry.path();
395                let dest_path = dst_dir.join(entry.file_name());
396
397                if entry.file_type().await?.is_dir() {
398                    // Push directories onto the stack for later processing.
399                    fs::create_dir_all(&dest_path).await?;
400                    stack.push((path, dest_path));
401                } else {
402                    // Copy files directly.
403                    let _ = fs::copy(&path, &dest_path).await?;
404                }
405            }
406        }
407
408        Ok(())
409    }
410
411    /// Generates HTML pages from processed content files.
412    ///
413    /// # Errors
414    ///
415    /// This function will return an error if:
416    /// - Reading from the content cache fails.
417    /// - A page cannot be generated or written to the output directory.
418    pub async fn generate_pages(&self, _config: &Config) -> Result<()> {
419        log::info!("Generating HTML pages");
420
421        let _content_cache = self.content_cache.read().await;
422
423        Ok(())
424    }
425}
426
427// Tests are also gated behind the "ssg" feature
428#[cfg(all(test, feature = "ssg"))]
429mod tests {
430    use super::*;
431    use tempfile::tempdir;
432
433    /// Sets up a temporary directory structure for testing.
434    ///
435    /// This function creates the necessary `content`, `templates`, and `public` directories
436    /// within a temporary folder and returns the `TempDir` instance along with a test `Config`.
437    async fn setup_test_directory(
438    ) -> Result<(tempfile::TempDir, Config)> {
439        let temp_dir = tempdir()?;
440        let base_path = temp_dir.path();
441
442        let content_dir = base_path.join("content");
443        let template_dir = base_path.join("templates");
444        let output_dir = base_path.join("public");
445
446        fs::create_dir(&content_dir).await?;
447        fs::create_dir(&template_dir).await?;
448        fs::create_dir(&output_dir).await?;
449
450        let config = Config::builder()
451            .site_name("Test Site")
452            .content_dir(content_dir)
453            .template_dir(template_dir)
454            .output_dir(output_dir)
455            .build()
456            .unwrap();
457
458        Ok((temp_dir, config))
459    }
460
461    #[tokio::test]
462    async fn test_engine_creation() -> Result<()> {
463        let (_temp_dir, _config) = setup_test_directory().await?;
464        let engine = Engine::new()?;
465        assert!(engine.content_cache.read().await.items.is_empty());
466        assert!(engine.template_cache.read().await.items.is_empty());
467        Ok(())
468    }
469
470    #[tokio::test]
471    async fn test_template_loading() -> Result<()> {
472        let (temp_dir, config) = setup_test_directory().await?;
473
474        // Create a test template file.
475        let template_content =
476            "<!DOCTYPE html><html><body>{{content}}</body></html>";
477        fs::write(
478            config.template_dir.join("default.html"),
479            template_content,
480        )
481        .await?;
482
483        let engine = Engine::new()?;
484        engine.load_templates(&config).await?;
485
486        let templates = engine.template_cache.read().await;
487        assert_eq!(
488            templates.items.get("default"),
489            Some(&template_content.to_string())
490        );
491
492        temp_dir.close()?;
493        Ok(())
494    }
495
496    #[tokio::test]
497    async fn test_template_loading_with_invalid_file() -> Result<()> {
498        let (_temp_dir, config) = setup_test_directory().await?;
499
500        // Create an invalid template file (e.g., not HTML).
501        fs::write(
502            config.template_dir.join("invalid.txt"),
503            "This is not a template.",
504        )
505        .await?;
506
507        let engine = Engine::new()?;
508        engine.load_templates(&config).await?;
509
510        let templates = engine.template_cache.read().await;
511        assert!(!templates.items.contains_key("invalid"));
512        Ok(())
513    }
514
515    #[tokio::test]
516    async fn test_frontmatter_extraction() -> Result<()> {
517        let engine = Engine::new()?;
518
519        let content = r#"---
520title: Test Post
521date: 2025-09-09
522tags: ["tag1", "tag2"]
523template: "default"
524---
525This is the main content."#;
526
527        let (metadata, body) = engine.extract_front_matter(content)?;
528        assert_eq!(metadata.get("title").unwrap(), "Test Post");
529        assert_eq!(metadata.get("date").unwrap(), "2025-09-09");
530        assert_eq!(
531            metadata.get("tags").unwrap(),
532            &serde_json::json!(["tag1", "tag2"])
533        );
534        assert_eq!(metadata.get("template").unwrap(), "default");
535        assert_eq!(body, "This is the main content.");
536
537        Ok(())
538    }
539
540    #[tokio::test]
541    async fn test_frontmatter_extraction_missing_metadata() -> Result<()>
542    {
543        let engine = Engine::new()?;
544
545        let content = "This content has no frontmatter.";
546        let (metadata, body) = engine.extract_front_matter(content)?;
547
548        assert!(metadata.is_empty());
549        assert_eq!(body, content);
550
551        Ok(())
552    }
553
554    #[tokio::test]
555    async fn test_content_processing() -> Result<()> {
556        let (temp_dir, config) = setup_test_directory().await?;
557
558        let content = r#"---
559title: Test Post
560date: 2025-09-09
561tags: ["tag1"]
562template: "default"
563---
564Test content"#;
565        fs::write(config.content_dir.join("test.md"), content).await?;
566
567        let engine = Engine::new()?;
568        engine.process_content_files(&config).await?;
569
570        let content_cache = engine.content_cache.read().await;
571        assert_eq!(content_cache.items.len(), 1);
572        let cached_file = content_cache
573            .items
574            .get(&config.content_dir.join("test.md"))
575            .unwrap();
576        assert_eq!(
577            cached_file.metadata.get("title").unwrap(),
578            "Test Post"
579        );
580
581        temp_dir.close()?;
582        Ok(())
583    }
584
585    #[tokio::test]
586    async fn test_content_processing_invalid_file() -> Result<()> {
587        let (temp_dir, config) = setup_test_directory().await?;
588
589        // Create an invalid content file (no frontmatter).
590        let content = "This file does not have valid frontmatter.";
591        fs::write(config.content_dir.join("invalid.md"), content)
592            .await?;
593
594        let engine = Engine::new()?;
595        engine.process_content_files(&config).await?;
596
597        let content_cache = engine.content_cache.read().await;
598        assert_eq!(content_cache.items.len(), 1);
599
600        let cached_file = content_cache
601            .items
602            .get(&config.content_dir.join("invalid.md"))
603            .unwrap();
604
605        // Since Markdown is converted to HTML, update the assertion accordingly.
606        let expected_html =
607            "<p>This file does not have valid frontmatter.</p>\n";
608        assert!(cached_file.metadata.is_empty());
609        assert_eq!(cached_file.content, expected_html);
610
611        temp_dir.close()?;
612        Ok(())
613    }
614
615    #[tokio::test]
616    async fn test_render_template() -> Result<()> {
617        let engine = Engine::new()?;
618
619        let content = ContentFile {
620            dest_path: PathBuf::from("output/test.html"),
621            metadata: HashMap::from([
622                ("title".to_string(), serde_json::json!("Test Title")),
623                ("author".to_string(), serde_json::json!("Jane Doe")),
624            ]),
625            content: "This is test content.".to_string(),
626        };
627
628        let template = "<html><head><title>{{ title }}</title></head><body>{{ content }}</body></html>";
629        let rendered = engine.render_template(template, &content)?;
630
631        assert!(rendered.contains("<title>Test Title</title>"));
632        assert!(rendered.contains("<body>This is test content.</body>"));
633
634        Ok(())
635    }
636
637    #[tokio::test]
638    async fn test_asset_copying() -> Result<()> {
639        let (temp_dir, config) = setup_test_directory().await?;
640
641        let assets_dir = config.content_dir.join("assets");
642        fs::create_dir(&assets_dir).await?;
643        fs::write(
644            assets_dir.join("style.css"),
645            "body { color: black; }",
646        )
647        .await?;
648
649        let engine = Engine::new()?;
650        engine.copy_assets(&config).await?;
651
652        assert!(config.output_dir.join("assets/style.css").exists());
653
654        temp_dir.close()?;
655        Ok(())
656    }
657
658    #[tokio::test]
659    async fn test_asset_copying_empty_directory() -> Result<()> {
660        let (temp_dir, config) = setup_test_directory().await?;
661
662        let assets_dir = config.content_dir.join("assets");
663        fs::create_dir(&assets_dir).await?;
664
665        let engine = Engine::new()?;
666        engine.copy_assets(&config).await?;
667
668        assert!(config.output_dir.join("assets").exists());
669        assert!(fs::read_dir(config.output_dir.join("assets"))
670            .await?
671            .next_entry()
672            .await?
673            .is_none());
674
675        temp_dir.close()?;
676        Ok(())
677    }
678}
679
680#[cfg(all(test, feature = "ssg"))]
681mod exhaustive_engine_tests {
682    //! The cache's eviction rule, template loading and rendering, and
683    //! asset copying — the parts of the engine the existing suite drives
684    //! only through a full `generate`.
685
686    use super::*;
687    use std::path::Path;
688
689    #[test]
690    fn size_cache_evicts_when_it_is_full_and_clears_on_request() {
691        let mut cache: SizeCache<String, u32> = SizeCache {
692            items: HashMap::new(),
693            max_size: 2,
694        };
695        assert!(cache.insert("a".into(), 1).is_none());
696        assert!(cache.insert("b".into(), 2).is_none());
697        assert_eq!(cache.items.len(), 2);
698
699        // At capacity: inserting a third evicts one rather than growing.
700        let _ = cache.insert("c".into(), 3);
701        assert_eq!(
702            cache.items.len(),
703            2,
704            "the cache grew past max_size"
705        );
706        assert!(cache.items.contains_key("c"), "the new entry is kept");
707
708        // Replacing an existing key returns the old value and evicts
709        // nothing: the entry is already there, so no room is needed.
710        assert_eq!(cache.insert("c".into(), 4), Some(3));
711        assert_eq!(
712            cache.items.len(),
713            2,
714            "a replacement evicted an entry"
715        );
716
717        cache.clear();
718        assert!(cache.items.is_empty());
719
720        let mut zero_cache: SizeCache<String, u32> = SizeCache::new(0);
721        let _ = zero_cache.insert("z".into(), 0);
722    }
723
724    #[test]
725    fn front_matter_is_split_from_the_body() {
726        let engine = Engine::new().expect("engine");
727
728        let (metadata, body) = engine
729            .extract_front_matter(
730                "---\ntitle: T\ncount: 2\n---\nBody text",
731            )
732            .expect("front matter splits");
733        assert_eq!(
734            metadata.get("title").and_then(|v| v.as_str()),
735            Some("T")
736        );
737        assert_eq!(body.trim(), "Body text");
738
739        // No fence: everything is body, and the metadata map is empty.
740        let (metadata, body) = engine
741            .extract_front_matter("Just content")
742            .expect("no front matter is not an error");
743        assert!(metadata.is_empty());
744        assert_eq!(body.trim(), "Just content");
745    }
746
747    #[test]
748    fn malformed_front_matter_is_reported() {
749        let engine = Engine::new().expect("engine");
750        assert!(engine
751            .extract_front_matter("---\ntitle: [\n---\nBody")
752            .is_err());
753    }
754
755    #[test]
756    fn templates_render_with_content_and_metadata_in_scope() {
757        let engine = Engine::new().expect("engine");
758        let mut metadata = HashMap::new();
759        let _ = metadata.insert(
760            "title".to_string(),
761            serde_json::Value::String("Hello".into()),
762        );
763        let content = ContentFile {
764            dest_path: PathBuf::from("out.html"),
765            metadata,
766            content: "<p>body</p>".to_string(),
767        };
768
769        let rendered = engine
770            .render_template(
771                "<h1>{{ title }}</h1>{{ content }}",
772                &content,
773            )
774            .expect("render");
775        assert!(rendered.contains("Hello"), "{rendered}");
776        assert!(rendered.contains("<p>body</p>"), "{rendered}");
777    }
778
779    #[test]
780    fn a_broken_template_is_an_error_not_a_panic() {
781        let engine = Engine::new().expect("engine");
782        let content = ContentFile {
783            dest_path: PathBuf::from("out.html"),
784            metadata: HashMap::new(),
785            content: String::new(),
786        };
787        assert!(engine.render_template("{% if %}", &content).is_err());
788    }
789
790    async fn config_in(dir: &Path) -> Config {
791        for sub in ["content", "templates", "public"] {
792            std::fs::create_dir_all(dir.join(sub)).expect("mkdir");
793        }
794        Config::builder()
795            .site_name("test")
796            .site_title("test")
797            .site_description("test")
798            .language("en-GB")
799            .base_url("https://example.com")
800            .content_dir(dir.join("content"))
801            .output_dir(dir.join("public"))
802            .template_dir(dir.join("templates"))
803            .build()
804            .expect("config")
805    }
806
807    #[tokio::test]
808    async fn load_templates_reads_every_file_in_the_template_dir() {
809        let dir = tempfile::tempdir().expect("tempdir");
810        let config = config_in(dir.path()).await;
811        std::fs::write(
812            config.template_dir.join("page.html"),
813            b"<p>{{ content }}</p>",
814        )
815        .expect("write template");
816
817        let engine = Engine::new().expect("engine");
818        engine.load_templates(&config).await.expect("load");
819    }
820
821    #[tokio::test]
822    async fn copy_assets_is_a_no_op_without_an_assets_directory() {
823        let dir = tempfile::tempdir().expect("tempdir");
824        let config = config_in(dir.path()).await;
825        let engine = Engine::new().expect("engine");
826        engine
827            .copy_assets(&config)
828            .await
829            .expect("no assets directory is not an error");
830    }
831
832    #[tokio::test]
833    async fn generate_pages_is_currently_a_no_op() {
834        let dir = tempfile::tempdir().expect("tempdir");
835        let config = config_in(dir.path()).await;
836        let engine = Engine::new().expect("engine");
837        engine.generate_pages(&config).await.expect("no-op");
838    }
839
840    #[tokio::test]
841    async fn engine_covers_cache_zero_and_filesystem_edge_cases() {
842        log::set_max_level(log::LevelFilter::Trace);
843
844        // 1. SizeCache with max_size = 0
845        let mut zero_cache: SizeCache<&str, &str> = SizeCache::new(0);
846        assert_eq!(zero_cache.insert("k", "v"), None);
847
848        let dir = tempfile::tempdir().expect("tempdir");
849        let config = config_in(dir.path()).await;
850        let engine = Engine::new().expect("engine");
851
852        // 2. process_content_file on a missing path
853        assert!(engine
854            .process_content_file(
855                &config.content_dir.join("missing.md"),
856                &config
857            )
858            .await
859            .is_err());
860
861        // 3. process_content_files skips non-.md files and processes .md files
862        std::fs::write(config.content_dir.join("notes.txt"), b"skip")
863            .expect("write txt");
864        std::fs::write(
865            config.content_dir.join("post.md"),
866            b"---\ntitle: Hello\n---\nBody",
867        )
868        .expect("write md");
869        engine
870            .process_content_files(&config)
871            .await
872            .expect("process content files");
873
874        // 4. render_template error when template references undefined variable
875        let cf = ContentFile {
876            dest_path: config.output_dir.join("post.html"),
877            metadata: HashMap::new(),
878            content: "<p>Body</p>".into(),
879        };
880        assert!(engine
881            .render_template("{{ undefined_var }}", &cf)
882            .is_err());
883
884        // 5. copy_assets with nested subdirectories and existing destination assets dir
885        let nested_asset_dir =
886            config.content_dir.join("assets").join("css");
887        std::fs::create_dir_all(&nested_asset_dir)
888            .expect("mkdir assets/css");
889        std::fs::write(
890            nested_asset_dir.join("site.css"),
891            b"body { margin: 0; }",
892        )
893        .expect("write css");
894        engine.copy_assets(&config).await.expect("first copy");
895        engine
896            .copy_assets(&config)
897            .await
898            .expect("second copy removes existing dest_assets_dir");
899        assert!(config
900            .output_dir
901            .join("assets")
902            .join("css")
903            .join("site.css")
904            .exists());
905
906        // 6. load_templates fails when an entry with .html extension is a directory
907        std::fs::create_dir_all(
908            config.template_dir.join("not_a_file.html"),
909        )
910        .expect("mkdir html");
911        assert!(engine.load_templates(&config).await.is_err());
912    }
913}