1use std::{
6 collections::HashMap,
7 fs,
8 io::Read,
9 path::{Path, PathBuf},
10};
11
12use thiserror::Error;
13use tracing::{debug, info};
14
15#[derive(Debug, Error)]
17pub enum AssetError {
18 #[error("IO error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("invalid asset path: {0}")]
24 InvalidPath(PathBuf),
25}
26
27pub type Result<T> = std::result::Result<T, AssetError>;
29
30#[derive(Debug, Clone, Default)]
32pub struct AssetManifest {
33 assets: HashMap<String, String>,
35}
36
37impl AssetManifest {
38 #[must_use]
40 pub fn new() -> Self {
41 Self::default()
42 }
43
44 pub fn add(&mut self, original: impl Into<String>, fingerprinted: impl Into<String>) {
46 self.assets.insert(original.into(), fingerprinted.into());
47 }
48
49 #[must_use]
51 pub fn get(&self, original: &str) -> Option<&str> {
52 self.assets.get(original).map(String::as_str)
53 }
54
55 #[must_use]
57 pub fn assets(&self) -> &HashMap<String, String> {
58 &self.assets
59 }
60
61 pub fn to_json(&self) -> String {
63 serde_json::to_string_pretty(&self.assets).unwrap_or_else(|_| "{}".to_string())
64 }
65}
66
67#[derive(Debug)]
69pub struct AssetProcessor {
70 fingerprint: bool,
72
73 fingerprint_extensions: Vec<String>,
75}
76
77impl AssetProcessor {
78 #[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 #[must_use]
100 pub fn with_fingerprint_extensions(mut self, extensions: Vec<String>) -> Self {
101 self.fingerprint_extensions = extensions;
102 self
103 }
104
105 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 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 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 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 if let Some(parent) = dest_path.parent() {
190 fs::create_dir_all(parent)?;
191 }
192
193 fs::copy(file_path, &dest_path)?;
195
196 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 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 let mut hash: u64 = 0xcbf29ce484222325;
218 for byte in &buffer {
219 hash ^= u64::from(*byte);
220 hash = hash.wrapping_mul(0x100000001b3);
221 }
222
223 Ok(format!("{hash:016x}")[..12].to_string())
225 }
226
227 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 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 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 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 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 let processor = AssetProcessor::new(true);
311 let manifest = processor.process(source.path(), dest.path()).unwrap();
312
313 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}