Skip to main content

typstify_generator/
assets.rs

1//! Asset processing and management.
2//!
3//! Handles copying static assets and optional fingerprinting for cache busting.
4
5use std::{
6    collections::HashMap,
7    fs,
8    io::Read,
9    path::{Path, PathBuf},
10};
11
12use thiserror::Error;
13use tracing::{debug, info};
14
15/// Asset processing errors.
16#[derive(Debug, Error)]
17pub enum AssetError {
18    /// IO error.
19    #[error("IO error: {0}")]
20    Io(#[from] std::io::Error),
21
22    /// Invalid asset path.
23    #[error("invalid asset path: {0}")]
24    InvalidPath(PathBuf),
25}
26
27/// Result type for asset operations.
28pub type Result<T> = std::result::Result<T, AssetError>;
29
30/// Asset manifest for tracking processed assets.
31#[derive(Debug, Clone, Default)]
32pub struct AssetManifest {
33    /// Mapping from original path to fingerprinted path.
34    assets: HashMap<String, String>,
35}
36
37impl AssetManifest {
38    /// Create a new empty manifest.
39    #[must_use]
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Add an asset to the manifest.
45    pub fn add(&mut self, original: impl Into<String>, fingerprinted: impl Into<String>) {
46        self.assets.insert(original.into(), fingerprinted.into());
47    }
48
49    /// Get the fingerprinted path for an asset.
50    #[must_use]
51    pub fn get(&self, original: &str) -> Option<&str> {
52        self.assets.get(original).map(String::as_str)
53    }
54
55    /// Get all assets in the manifest.
56    #[must_use]
57    pub fn assets(&self) -> &HashMap<String, String> {
58        &self.assets
59    }
60
61    /// Serialize manifest to JSON.
62    pub fn to_json(&self) -> String {
63        serde_json::to_string_pretty(&self.assets).unwrap_or_else(|_| "{}".to_string())
64    }
65}
66
67/// Asset processor for copying and optionally fingerprinting static files.
68#[derive(Debug)]
69pub struct AssetProcessor {
70    /// Whether to fingerprint assets.
71    fingerprint: bool,
72
73    /// File extensions to fingerprint.
74    fingerprint_extensions: Vec<String>,
75}
76
77impl AssetProcessor {
78    /// Create a new asset processor.
79    #[must_use]
80    pub fn new(fingerprint: bool) -> Self {
81        Self {
82            fingerprint,
83            fingerprint_extensions: vec![
84                "css".to_string(),
85                "js".to_string(),
86                "woff".to_string(),
87                "woff2".to_string(),
88                "png".to_string(),
89                "jpg".to_string(),
90                "jpeg".to_string(),
91                "gif".to_string(),
92                "svg".to_string(),
93                "webp".to_string(),
94            ],
95        }
96    }
97
98    /// Set which extensions should be fingerprinted.
99    #[must_use]
100    pub fn with_fingerprint_extensions(mut self, extensions: Vec<String>) -> Self {
101        self.fingerprint_extensions = extensions;
102        self
103    }
104
105    /// Process all assets from source to destination directory.
106    pub fn process(&self, source_dir: &Path, dest_dir: &Path) -> Result<AssetManifest> {
107        info!(
108            source = %source_dir.display(),
109            dest = %dest_dir.display(),
110            "processing assets"
111        );
112
113        let mut manifest = AssetManifest::new();
114
115        if !source_dir.exists() {
116            debug!("source directory does not exist, skipping");
117            return Ok(manifest);
118        }
119
120        self.process_dir(source_dir, source_dir, dest_dir, &mut manifest)?;
121
122        info!(count = manifest.assets.len(), "assets processed");
123        Ok(manifest)
124    }
125
126    /// Recursively process a directory.
127    fn process_dir(
128        &self,
129        base_dir: &Path,
130        current_dir: &Path,
131        dest_base: &Path,
132        manifest: &mut AssetManifest,
133    ) -> Result<()> {
134        for entry in fs::read_dir(current_dir)? {
135            let entry = entry?;
136            let path = entry.path();
137
138            // Skip hidden files/directories
139            if path
140                .file_name()
141                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
142            {
143                continue;
144            }
145
146            if path.is_dir() {
147                self.process_dir(base_dir, &path, dest_base, manifest)?;
148            } else if path.is_file() {
149                self.process_file(base_dir, &path, dest_base, manifest)?;
150            }
151        }
152
153        Ok(())
154    }
155
156    /// Process a single file.
157    fn process_file(
158        &self,
159        base_dir: &Path,
160        file_path: &Path,
161        dest_base: &Path,
162        manifest: &mut AssetManifest,
163    ) -> Result<()> {
164        let relative = file_path
165            .strip_prefix(base_dir)
166            .map_err(|_| AssetError::InvalidPath(file_path.to_path_buf()))?;
167
168        let should_fingerprint = self.fingerprint
169            && file_path.extension().is_some_and(|ext| {
170                self.fingerprint_extensions
171                    .contains(&ext.to_string_lossy().to_string())
172            });
173
174        let dest_relative = if should_fingerprint {
175            let hash = self.compute_hash(file_path)?;
176            let stem = file_path.file_stem().unwrap_or_default().to_string_lossy();
177            let ext = file_path.extension().unwrap_or_default().to_string_lossy();
178
179            let fingerprinted_name = format!("{stem}.{hash}.{ext}");
180            let parent = relative.parent().unwrap_or(Path::new(""));
181            parent.join(&fingerprinted_name)
182        } else {
183            relative.to_path_buf()
184        };
185
186        let dest_path = dest_base.join(&dest_relative);
187
188        // Ensure destination directory exists
189        if let Some(parent) = dest_path.parent() {
190            fs::create_dir_all(parent)?;
191        }
192
193        // Copy the file
194        fs::copy(file_path, &dest_path)?;
195
196        // Add to manifest
197        let orig_path = format!("/{}", relative.display()).replace('\\', "/");
198        let dest_path_str = format!("/{}", dest_relative.display()).replace('\\', "/");
199        manifest.add(orig_path, dest_path_str);
200
201        debug!(
202            src = %file_path.display(),
203            dest = %dest_path.display(),
204            "copied asset"
205        );
206
207        Ok(())
208    }
209
210    /// Compute a short hash of file contents for fingerprinting.
211    fn compute_hash(&self, path: &Path) -> Result<String> {
212        let mut file = fs::File::open(path)?;
213        let mut buffer = Vec::new();
214        file.read_to_end(&mut buffer)?;
215
216        // Simple hash using FNV-1a
217        let mut hash: u64 = 0xcbf29ce484222325;
218        for byte in &buffer {
219            hash ^= u64::from(*byte);
220            hash = hash.wrapping_mul(0x100000001b3);
221        }
222
223        // Return first 12 hex characters
224        Ok(format!("{hash:016x}")[..12].to_string())
225    }
226
227    /// Copy a single file without fingerprinting.
228    pub fn copy_file(source: &Path, dest: &Path) -> Result<()> {
229        if let Some(parent) = dest.parent() {
230            fs::create_dir_all(parent)?;
231        }
232        fs::copy(source, dest)?;
233        Ok(())
234    }
235
236    /// Create a directory if it doesn't exist.
237    pub fn ensure_dir(path: &Path) -> Result<()> {
238        if !path.exists() {
239            fs::create_dir_all(path)?;
240        }
241        Ok(())
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use std::io::Write;
248
249    use tempfile::TempDir;
250
251    use super::*;
252
253    #[test]
254    fn test_asset_manifest() {
255        let mut manifest = AssetManifest::new();
256        manifest.add("/css/style.css", "/css/style.abc12345.css");
257        manifest.add("/js/main.js", "/js/main.def67890.js");
258
259        assert_eq!(
260            manifest.get("/css/style.css"),
261            Some("/css/style.abc12345.css")
262        );
263        assert_eq!(manifest.get("/js/main.js"), Some("/js/main.def67890.js"));
264        assert!(manifest.get("/other.txt").is_none());
265    }
266
267    #[test]
268    fn test_manifest_to_json() {
269        let mut manifest = AssetManifest::new();
270        manifest.add("/style.css", "/style.abc.css");
271
272        let json = manifest.to_json();
273        assert!(json.contains(r#""/style.css": "/style.abc.css""#));
274    }
275
276    #[test]
277    fn test_process_assets() {
278        let source = TempDir::new().unwrap();
279        let dest = TempDir::new().unwrap();
280
281        // Create test files
282        let css_path = source.path().join("style.css");
283        let mut css_file = fs::File::create(&css_path).unwrap();
284        css_file.write_all(b"body { color: red; }").unwrap();
285
286        let txt_path = source.path().join("readme.txt");
287        let mut txt_file = fs::File::create(&txt_path).unwrap();
288        txt_file.write_all(b"Hello world").unwrap();
289
290        // Process without fingerprinting
291        let processor = AssetProcessor::new(false);
292        let manifest = processor.process(source.path(), dest.path()).unwrap();
293
294        assert!(dest.path().join("style.css").exists());
295        assert!(dest.path().join("readme.txt").exists());
296        assert_eq!(manifest.assets().len(), 2);
297    }
298
299    #[test]
300    fn test_process_with_fingerprinting() {
301        let source = TempDir::new().unwrap();
302        let dest = TempDir::new().unwrap();
303
304        // Create a CSS file
305        let css_path = source.path().join("style.css");
306        let mut css_file = fs::File::create(&css_path).unwrap();
307        css_file.write_all(b"body { color: blue; }").unwrap();
308
309        // Process with fingerprinting
310        let processor = AssetProcessor::new(true);
311        let manifest = processor.process(source.path(), dest.path()).unwrap();
312
313        // Original file should map to fingerprinted version
314        let fingerprinted = manifest.get("/style.css").unwrap();
315        assert!(fingerprinted.starts_with("/style."));
316        assert!(fingerprinted.ends_with(".css"));
317        assert!(fingerprinted.len() > "/style.css".len());
318    }
319
320    #[test]
321    fn test_compute_hash_deterministic() {
322        let dir = TempDir::new().unwrap();
323        let path = dir.path().join("test.txt");
324        let mut file = fs::File::create(&path).unwrap();
325        file.write_all(b"test content").unwrap();
326        drop(file);
327
328        let processor = AssetProcessor::new(true);
329        let hash1 = processor.compute_hash(&path).unwrap();
330        let hash2 = processor.compute_hash(&path).unwrap();
331
332        assert_eq!(hash1, hash2);
333        assert_eq!(hash1.len(), 12);
334    }
335
336    #[test]
337    fn test_compute_hash_uniqueness() {
338        let dir = TempDir::new().unwrap();
339        let path1 = dir.path().join("file1.txt");
340        let path2 = dir.path().join("file2.txt");
341        fs::write(&path1, b"content A").unwrap();
342        fs::write(&path2, b"content B").unwrap();
343
344        let processor = AssetProcessor::new(true);
345        let hash1 = processor.compute_hash(&path1).unwrap();
346        let hash2 = processor.compute_hash(&path2).unwrap();
347
348        assert_ne!(hash1, hash2);
349    }
350
351    #[test]
352    fn test_ensure_dir() {
353        let dir = TempDir::new().unwrap();
354        let nested = dir.path().join("a/b/c");
355
356        assert!(!nested.exists());
357        AssetProcessor::ensure_dir(&nested).unwrap();
358        assert!(nested.exists());
359    }
360
361    #[test]
362    fn test_to_json_valid() {
363        let mut manifest = AssetManifest::new();
364        manifest.add("style.css", "style.abc123.css");
365        manifest.add("script.js", "script.def456.js");
366        let json = manifest.to_json();
367        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
368        assert!(parsed.is_object());
369        assert_eq!(parsed["style.css"], "style.abc123.css");
370        assert_eq!(parsed["script.js"], "script.def456.js");
371    }
372
373    #[test]
374    fn test_to_json_special_chars() {
375        let mut manifest = AssetManifest::new();
376        manifest.add("path with spaces.css", "path with spaces.abc.css");
377        manifest.add("quotes\".js", "quotes\".def.js");
378        let json = manifest.to_json();
379        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
380        assert_eq!(parsed["path with spaces.css"], "path with spaces.abc.css");
381    }
382}