1use crate::content::Content;
4use crate::renderer::RenderedOutput;
5use anyhow::Result;
6use std::path::PathBuf;
7use typub_adapters_core::OutputFormat;
8use typub_config::Config;
9
10pub 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 pub fn cache_dir(&self, content: &Content) -> PathBuf {
22 self.config.output_dir.join(content.slug())
23 }
24
25 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 let cache_mtime = std::fs::metadata(&cache_path)?.modified()?;
35 let content_mtime = std::fs::metadata(&content.content_file)?.modified()?;
36
37 Ok(cache_mtime > content_mtime)
39 }
40
41 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"), OutputFormat::Pdf => dir.join("content.pdf"),
48 }
49 }
50
51 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 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(50));
247
248 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 std::thread::sleep(std::time::Duration::from_millis(50));
268
269 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 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 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}