Skip to main content

typub_engine/
cache.rs

1//! Output caching for rendered content
2
3use crate::content::Content;
4use crate::renderer::RenderedOutput;
5use anyhow::Result;
6use std::path::PathBuf;
7use typub_adapters_core::OutputFormat;
8use typub_config::Config;
9
10/// Cache manager for rendered outputs
11pub struct Cache<'a> {
12    config: &'a Config,
13}
14
15impl<'a> Cache<'a> {
16    pub fn new(config: &'a Config) -> Self {
17        Self { config }
18    }
19
20    /// Get the cache directory for a post
21    pub fn cache_dir(&self, content: &Content) -> PathBuf {
22        self.config.output_dir.join(content.slug())
23    }
24
25    /// Check if cached output exists and is fresh
26    pub fn is_fresh(&self, content: &Content, format: OutputFormat) -> Result<bool> {
27        let cache_path = self.output_path(content, format);
28
29        if !cache_path.exists() {
30            return Ok(false);
31        }
32
33        // Compare modification times
34        let cache_mtime = std::fs::metadata(&cache_path)?.modified()?;
35        let content_mtime = std::fs::metadata(&content.content_file)?.modified()?;
36
37        // Cache is fresh if it's newer than content
38        Ok(cache_mtime > content_mtime)
39    }
40
41    /// Get the path where cached output would be stored
42    pub fn output_path(&self, content: &Content, format: OutputFormat) -> PathBuf {
43        let dir = self.cache_dir(content);
44        match format {
45            OutputFormat::Html | OutputFormat::HtmlFragment => dir.join("content.html"),
46            OutputFormat::Png => dir.join("slide-1.png"), // Check first slide
47            OutputFormat::Pdf => dir.join("content.pdf"),
48        }
49    }
50
51    /// Load cached output if available
52    pub fn load(&self, content: &Content, format: OutputFormat) -> Result<Option<RenderedOutput>> {
53        if !self.is_fresh(content, format)? {
54            return Ok(None);
55        }
56
57        let path = self.output_path(content, format);
58
59        match format {
60            OutputFormat::Html | OutputFormat::HtmlFragment => {
61                let html = std::fs::read_to_string(&path)?;
62                Ok(Some(RenderedOutput {
63                    format,
64                    paths: vec![path],
65                    html: Some(html),
66                }))
67            }
68            OutputFormat::Png => {
69                // Collect all PNG files
70                let dir = self.cache_dir(content);
71                let mut paths = Vec::new();
72                for entry in std::fs::read_dir(&dir)? {
73                    let entry = entry?;
74                    let path = entry.path();
75                    if path.extension().is_some_and(|ext| ext == "png") {
76                        paths.push(path);
77                    }
78                }
79                paths.sort();
80                Ok(Some(RenderedOutput {
81                    format,
82                    paths,
83                    html: None,
84                }))
85            }
86            OutputFormat::Pdf => Ok(Some(RenderedOutput {
87                format,
88                paths: vec![path],
89                html: None,
90            })),
91        }
92    }
93
94    /// Clear cached outputs for a post
95    pub fn clear(&self, content: &Content) -> Result<()> {
96        let dir = self.cache_dir(content);
97        if dir.exists() {
98            std::fs::remove_dir_all(&dir)?;
99        }
100        Ok(())
101    }
102
103    /// Clear all cached outputs
104    pub fn clear_all(&self) -> Result<()> {
105        let dir = &self.config.output_dir;
106        if dir.exists() {
107            std::fs::remove_dir_all(dir)?;
108        }
109        Ok(())
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    #![allow(clippy::expect_used)]
116    use super::*;
117    use crate::content::{Content, ContentFormat, ContentMeta};
118    use chrono::NaiveDate;
119    use std::collections::HashMap;
120    use typub_config::Config;
121
122    fn make_config(output_dir: &std::path::Path) -> Config {
123        Config {
124            content_dir: std::path::PathBuf::from("posts"),
125            output_dir: output_dir.to_path_buf(),
126            storage: None,
127            published: None,
128            theme: None,
129            internal_link_target: None,
130            preamble: None,
131            platforms: HashMap::new(),
132        }
133    }
134
135    fn make_content(dir: &std::path::Path, slug: &str) -> Content {
136        let post_dir = dir.join(slug);
137        std::fs::create_dir_all(&post_dir).expect("create dir");
138        let content_file = post_dir.join("content.typ");
139        std::fs::write(&content_file, "test content").expect("write file");
140
141        Content {
142            path: post_dir,
143            meta: ContentMeta {
144                title: slug.to_string(),
145                created: NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid date"),
146                updated: None,
147                tags: vec![],
148                categories: vec![],
149                published: None,
150                theme: None,
151                internal_link_target: None,
152                preamble: None,
153                platforms: HashMap::new(),
154            },
155            content_file,
156            source_format: ContentFormat::Typst,
157            slides_file: None,
158            assets: vec![],
159        }
160    }
161
162    #[test]
163    fn test_cache_dir() {
164        let dir = tempfile::tempdir().expect("create temp dir");
165        let config = make_config(dir.path().join("out").as_ref());
166        let content = make_content(dir.path(), "my-post");
167        let cache = Cache::new(&config);
168
169        assert_eq!(
170            cache.cache_dir(&content),
171            dir.path().join("out").join("my-post")
172        );
173    }
174
175    #[test]
176    fn test_output_path_html() {
177        let dir = tempfile::tempdir().expect("create temp dir");
178        let config = make_config(dir.path().join("out").as_ref());
179        let content = make_content(dir.path(), "post");
180        let cache = Cache::new(&config);
181
182        assert_eq!(
183            cache.output_path(&content, OutputFormat::Html),
184            dir.path().join("out/post/content.html")
185        );
186        assert_eq!(
187            cache.output_path(&content, OutputFormat::HtmlFragment),
188            dir.path().join("out/post/content.html")
189        );
190    }
191
192    #[test]
193    fn test_output_path_png() {
194        let dir = tempfile::tempdir().expect("create temp dir");
195        let config = make_config(dir.path().join("out").as_ref());
196        let content = make_content(dir.path(), "post");
197        let cache = Cache::new(&config);
198
199        assert_eq!(
200            cache.output_path(&content, OutputFormat::Png),
201            dir.path().join("out/post/slide-1.png")
202        );
203    }
204
205    #[test]
206    fn test_output_path_pdf() {
207        let dir = tempfile::tempdir().expect("create temp dir");
208        let config = make_config(dir.path().join("out").as_ref());
209        let content = make_content(dir.path(), "post");
210        let cache = Cache::new(&config);
211
212        assert_eq!(
213            cache.output_path(&content, OutputFormat::Pdf),
214            dir.path().join("out/post/content.pdf")
215        );
216    }
217
218    #[test]
219    fn test_is_fresh_no_cache() {
220        let dir = tempfile::tempdir().expect("create temp dir");
221        let config = make_config(dir.path().join("out").as_ref());
222        let content = make_content(dir.path(), "post");
223        let cache = Cache::new(&config);
224
225        assert!(
226            !cache
227                .is_fresh(&content, OutputFormat::Html)
228                .expect("check freshness")
229        );
230    }
231
232    #[test]
233    fn test_is_fresh_stale_cache() {
234        let dir = tempfile::tempdir().expect("create temp dir");
235        let config = make_config(dir.path().join("out").as_ref());
236        let content = make_content(dir.path(), "post");
237        let cache = Cache::new(&config);
238
239        // Create cache file FIRST (older)
240        let cache_path = cache.output_path(&content, OutputFormat::Html);
241        std::fs::create_dir_all(cache_path.parent().expect("cache path has parent"))
242            .expect("create dir");
243        std::fs::write(&cache_path, "old html").expect("write file");
244
245        // Sleep to ensure mtime difference
246        std::thread::sleep(std::time::Duration::from_millis(50));
247
248        // Touch content file (newer)
249        std::fs::write(&content.content_file, "updated content").expect("write file");
250
251        assert!(
252            !cache
253                .is_fresh(&content, OutputFormat::Html)
254                .expect("check freshness")
255        );
256    }
257
258    #[test]
259    fn test_is_fresh_valid_cache() {
260        let dir = tempfile::tempdir().expect("create temp dir");
261        let config = make_config(dir.path().join("out").as_ref());
262        let content = make_content(dir.path(), "post");
263        let cache = Cache::new(&config);
264
265        // Content file already exists from make_content
266        // Sleep to ensure mtime difference
267        std::thread::sleep(std::time::Duration::from_millis(50));
268
269        // Create cache file AFTER (newer)
270        let cache_path = cache.output_path(&content, OutputFormat::Html);
271        std::fs::create_dir_all(cache_path.parent().expect("cache path has parent"))
272            .expect("create dir");
273        std::fs::write(&cache_path, "<html>cached</html>").expect("write file");
274
275        assert!(
276            cache
277                .is_fresh(&content, OutputFormat::Html)
278                .expect("check freshness")
279        );
280    }
281
282    #[test]
283    fn test_load_fresh_html() {
284        let dir = tempfile::tempdir().expect("create temp dir");
285        let config = make_config(dir.path().join("out").as_ref());
286        let content = make_content(dir.path(), "post");
287        let cache = Cache::new(&config);
288
289        std::thread::sleep(std::time::Duration::from_millis(50));
290
291        let cache_path = cache.output_path(&content, OutputFormat::Html);
292        std::fs::create_dir_all(cache_path.parent().expect("cache path has parent"))
293            .expect("create dir");
294        std::fs::write(&cache_path, "<html>cached</html>").expect("write file");
295
296        let loaded = cache
297            .load(&content, OutputFormat::Html)
298            .expect("load cache")
299            .expect("cache should exist");
300        assert_eq!(loaded.format, OutputFormat::Html);
301        assert_eq!(loaded.html.as_deref(), Some("<html>cached</html>"));
302    }
303
304    #[test]
305    fn test_load_stale_returns_none() {
306        let dir = tempfile::tempdir().expect("create temp dir");
307        let config = make_config(dir.path().join("out").as_ref());
308        let content = make_content(dir.path(), "post");
309        let cache = Cache::new(&config);
310
311        // Create stale cache
312        let cache_path = cache.output_path(&content, OutputFormat::Html);
313        std::fs::create_dir_all(cache_path.parent().expect("cache path has parent"))
314            .expect("create dir");
315        std::fs::write(&cache_path, "old").expect("write file");
316
317        std::thread::sleep(std::time::Duration::from_millis(50));
318        std::fs::write(&content.content_file, "new content").expect("write file");
319
320        assert!(
321            cache
322                .load(&content, OutputFormat::Html)
323                .expect("load cache")
324                .is_none()
325        );
326    }
327
328    #[test]
329    fn test_clear() {
330        let dir = tempfile::tempdir().expect("create temp dir");
331        let config = make_config(dir.path().join("out").as_ref());
332        let content = make_content(dir.path(), "post");
333        let cache = Cache::new(&config);
334
335        let cache_dir = cache.cache_dir(&content);
336        std::fs::create_dir_all(&cache_dir).expect("create dir");
337        std::fs::write(cache_dir.join("content.html"), "html").expect("write file");
338
339        cache.clear(&content).expect("clear cache");
340        assert!(!cache_dir.exists());
341    }
342
343    #[test]
344    fn test_clear_nonexistent_is_ok() {
345        let dir = tempfile::tempdir().expect("create temp dir");
346        let config = make_config(dir.path().join("out").as_ref());
347        let content = make_content(dir.path(), "post");
348        let cache = Cache::new(&config);
349
350        // Should not error even if directory doesn't exist
351        cache.clear(&content).expect("clear cache");
352    }
353
354    #[test]
355    fn test_clear_all() {
356        let dir = tempfile::tempdir().expect("create temp dir");
357        let out_dir = dir.path().join("out");
358        std::fs::create_dir_all(out_dir.join("post1")).expect("create dir");
359        std::fs::create_dir_all(out_dir.join("post2")).expect("create dir");
360
361        let config = make_config(&out_dir);
362        let cache = Cache::new(&config);
363
364        cache.clear_all().expect("clear all cache");
365        assert!(!out_dir.exists());
366    }
367
368    #[test]
369    fn test_load_fresh_png() {
370        let dir = tempfile::tempdir().expect("create temp dir");
371        let config = make_config(dir.path().join("out").as_ref());
372        let content = make_content(dir.path(), "slides");
373        let cache = Cache::new(&config);
374
375        std::thread::sleep(std::time::Duration::from_millis(50));
376
377        let cache_dir = cache.cache_dir(&content);
378        std::fs::create_dir_all(&cache_dir).expect("create dir");
379        std::fs::write(cache_dir.join("slide-1.png"), "png1").expect("write file");
380        std::fs::write(cache_dir.join("slide-2.png"), "png2").expect("write file");
381
382        let loaded = cache
383            .load(&content, OutputFormat::Png)
384            .expect("load cache")
385            .expect("cache should exist");
386        assert_eq!(loaded.format, OutputFormat::Png);
387        assert_eq!(loaded.paths.len(), 2);
388        assert!(loaded.html.is_none());
389    }
390}