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        let mut json = String::from("{\n");
64        let entries: Vec<_> = self.assets.iter().collect();
65        for (i, (orig, fp)) in entries.iter().enumerate() {
66            json.push_str(&format!(r#"  "{orig}": "{fp}""#));
67            if i < entries.len() - 1 {
68                json.push(',');
69            }
70            json.push('\n');
71        }
72        json.push('}');
73        json
74    }
75}
76
77/// Asset processor for copying and optionally fingerprinting static files.
78#[derive(Debug)]
79pub struct AssetProcessor {
80    /// Whether to fingerprint assets.
81    fingerprint: bool,
82
83    /// File extensions to fingerprint.
84    fingerprint_extensions: Vec<String>,
85}
86
87impl AssetProcessor {
88    /// Create a new asset processor.
89    #[must_use]
90    pub fn new(fingerprint: bool) -> Self {
91        Self {
92            fingerprint,
93            fingerprint_extensions: vec![
94                "css".to_string(),
95                "js".to_string(),
96                "woff".to_string(),
97                "woff2".to_string(),
98                "png".to_string(),
99                "jpg".to_string(),
100                "jpeg".to_string(),
101                "gif".to_string(),
102                "svg".to_string(),
103                "webp".to_string(),
104            ],
105        }
106    }
107
108    /// Set which extensions should be fingerprinted.
109    #[must_use]
110    pub fn with_fingerprint_extensions(mut self, extensions: Vec<String>) -> Self {
111        self.fingerprint_extensions = extensions;
112        self
113    }
114
115    /// Process all assets from source to destination directory.
116    pub fn process(&self, source_dir: &Path, dest_dir: &Path) -> Result<AssetManifest> {
117        info!(
118            source = %source_dir.display(),
119            dest = %dest_dir.display(),
120            "processing assets"
121        );
122
123        let mut manifest = AssetManifest::new();
124
125        if !source_dir.exists() {
126            debug!("source directory does not exist, skipping");
127            return Ok(manifest);
128        }
129
130        self.process_dir(source_dir, source_dir, dest_dir, &mut manifest)?;
131
132        info!(count = manifest.assets.len(), "assets processed");
133        Ok(manifest)
134    }
135
136    /// Recursively process a directory.
137    fn process_dir(
138        &self,
139        base_dir: &Path,
140        current_dir: &Path,
141        dest_base: &Path,
142        manifest: &mut AssetManifest,
143    ) -> Result<()> {
144        for entry in fs::read_dir(current_dir)? {
145            let entry = entry?;
146            let path = entry.path();
147
148            // Skip hidden files/directories
149            if path
150                .file_name()
151                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
152            {
153                continue;
154            }
155
156            if path.is_dir() {
157                self.process_dir(base_dir, &path, dest_base, manifest)?;
158            } else if path.is_file() {
159                self.process_file(base_dir, &path, dest_base, manifest)?;
160            }
161        }
162
163        Ok(())
164    }
165
166    /// Process a single file.
167    fn process_file(
168        &self,
169        base_dir: &Path,
170        file_path: &Path,
171        dest_base: &Path,
172        manifest: &mut AssetManifest,
173    ) -> Result<()> {
174        let relative = file_path
175            .strip_prefix(base_dir)
176            .map_err(|_| AssetError::InvalidPath(file_path.to_path_buf()))?;
177
178        let should_fingerprint = self.fingerprint
179            && file_path.extension().is_some_and(|ext| {
180                self.fingerprint_extensions
181                    .contains(&ext.to_string_lossy().to_string())
182            });
183
184        let dest_relative = if should_fingerprint {
185            let hash = self.compute_hash(file_path)?;
186            let stem = file_path.file_stem().unwrap_or_default().to_string_lossy();
187            let ext = file_path.extension().unwrap_or_default().to_string_lossy();
188
189            let fingerprinted_name = format!("{stem}.{hash}.{ext}");
190            let parent = relative.parent().unwrap_or(Path::new(""));
191            parent.join(&fingerprinted_name)
192        } else {
193            relative.to_path_buf()
194        };
195
196        let dest_path = dest_base.join(&dest_relative);
197
198        // Ensure destination directory exists
199        if let Some(parent) = dest_path.parent() {
200            fs::create_dir_all(parent)?;
201        }
202
203        // Copy the file
204        fs::copy(file_path, &dest_path)?;
205
206        // Add to manifest
207        let orig_path = format!("/{}", relative.display()).replace('\\', "/");
208        let dest_path_str = format!("/{}", dest_relative.display()).replace('\\', "/");
209        manifest.add(orig_path, dest_path_str);
210
211        debug!(
212            src = %file_path.display(),
213            dest = %dest_path.display(),
214            "copied asset"
215        );
216
217        Ok(())
218    }
219
220    /// Compute a short hash of file contents for fingerprinting.
221    fn compute_hash(&self, path: &Path) -> Result<String> {
222        let mut file = fs::File::open(path)?;
223        let mut buffer = Vec::new();
224        file.read_to_end(&mut buffer)?;
225
226        // Simple hash using FNV-1a
227        let mut hash: u64 = 0xcbf29ce484222325;
228        for byte in &buffer {
229            hash ^= u64::from(*byte);
230            hash = hash.wrapping_mul(0x100000001b3);
231        }
232
233        // Return first 8 hex characters
234        Ok(format!("{hash:016x}")[..8].to_string())
235    }
236
237    /// Copy a single file without fingerprinting.
238    pub fn copy_file(source: &Path, dest: &Path) -> Result<()> {
239        if let Some(parent) = dest.parent() {
240            fs::create_dir_all(parent)?;
241        }
242        fs::copy(source, dest)?;
243        Ok(())
244    }
245
246    /// Create a directory if it doesn't exist.
247    pub fn ensure_dir(path: &Path) -> Result<()> {
248        if !path.exists() {
249            fs::create_dir_all(path)?;
250        }
251        Ok(())
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::io::Write;
258
259    use tempfile::TempDir;
260
261    use super::*;
262
263    #[test]
264    fn test_asset_manifest() {
265        let mut manifest = AssetManifest::new();
266        manifest.add("/css/style.css", "/css/style.abc12345.css");
267        manifest.add("/js/main.js", "/js/main.def67890.js");
268
269        assert_eq!(
270            manifest.get("/css/style.css"),
271            Some("/css/style.abc12345.css")
272        );
273        assert_eq!(manifest.get("/js/main.js"), Some("/js/main.def67890.js"));
274        assert!(manifest.get("/other.txt").is_none());
275    }
276
277    #[test]
278    fn test_manifest_to_json() {
279        let mut manifest = AssetManifest::new();
280        manifest.add("/style.css", "/style.abc.css");
281
282        let json = manifest.to_json();
283        assert!(json.contains(r#""/style.css": "/style.abc.css""#));
284    }
285
286    #[test]
287    fn test_process_assets() {
288        let source = TempDir::new().unwrap();
289        let dest = TempDir::new().unwrap();
290
291        // Create test files
292        let css_path = source.path().join("style.css");
293        let mut css_file = fs::File::create(&css_path).unwrap();
294        css_file.write_all(b"body { color: red; }").unwrap();
295
296        let txt_path = source.path().join("readme.txt");
297        let mut txt_file = fs::File::create(&txt_path).unwrap();
298        txt_file.write_all(b"Hello world").unwrap();
299
300        // Process without fingerprinting
301        let processor = AssetProcessor::new(false);
302        let manifest = processor.process(source.path(), dest.path()).unwrap();
303
304        assert!(dest.path().join("style.css").exists());
305        assert!(dest.path().join("readme.txt").exists());
306        assert_eq!(manifest.assets().len(), 2);
307    }
308
309    #[test]
310    fn test_process_with_fingerprinting() {
311        let source = TempDir::new().unwrap();
312        let dest = TempDir::new().unwrap();
313
314        // Create a CSS file
315        let css_path = source.path().join("style.css");
316        let mut css_file = fs::File::create(&css_path).unwrap();
317        css_file.write_all(b"body { color: blue; }").unwrap();
318
319        // Process with fingerprinting
320        let processor = AssetProcessor::new(true);
321        let manifest = processor.process(source.path(), dest.path()).unwrap();
322
323        // Original file should map to fingerprinted version
324        let fingerprinted = manifest.get("/style.css").unwrap();
325        assert!(fingerprinted.starts_with("/style."));
326        assert!(fingerprinted.ends_with(".css"));
327        assert!(fingerprinted.len() > "/style.css".len());
328    }
329
330    #[test]
331    fn test_compute_hash_deterministic() {
332        let dir = TempDir::new().unwrap();
333        let path = dir.path().join("test.txt");
334        let mut file = fs::File::create(&path).unwrap();
335        file.write_all(b"test content").unwrap();
336        drop(file);
337
338        let processor = AssetProcessor::new(true);
339        let hash1 = processor.compute_hash(&path).unwrap();
340        let hash2 = processor.compute_hash(&path).unwrap();
341
342        assert_eq!(hash1, hash2);
343        assert_eq!(hash1.len(), 8);
344    }
345
346    #[test]
347    fn test_ensure_dir() {
348        let dir = TempDir::new().unwrap();
349        let nested = dir.path().join("a/b/c");
350
351        assert!(!nested.exists());
352        AssetProcessor::ensure_dir(&nested).unwrap();
353        assert!(nested.exists());
354    }
355}