use std::{
collections::HashMap,
fs,
io::Read,
path::{Path, PathBuf},
};
use thiserror::Error;
use tracing::{debug, info};
#[derive(Debug, Error)]
pub enum AssetError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid asset path: {0}")]
InvalidPath(PathBuf),
}
pub type Result<T> = std::result::Result<T, AssetError>;
#[derive(Debug, Clone, Default)]
pub struct AssetManifest {
assets: HashMap<String, String>,
}
impl AssetManifest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, original: impl Into<String>, fingerprinted: impl Into<String>) {
self.assets.insert(original.into(), fingerprinted.into());
}
#[must_use]
pub fn get(&self, original: &str) -> Option<&str> {
self.assets.get(original).map(String::as_str)
}
#[must_use]
pub fn assets(&self) -> &HashMap<String, String> {
&self.assets
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(&self.assets).unwrap_or_else(|_| "{}".to_string())
}
}
#[derive(Debug)]
pub struct AssetProcessor {
fingerprint: bool,
fingerprint_extensions: Vec<String>,
}
impl AssetProcessor {
#[must_use]
pub fn new(fingerprint: bool) -> Self {
Self {
fingerprint,
fingerprint_extensions: vec![
"css".to_string(),
"js".to_string(),
"woff".to_string(),
"woff2".to_string(),
"png".to_string(),
"jpg".to_string(),
"jpeg".to_string(),
"gif".to_string(),
"svg".to_string(),
"webp".to_string(),
],
}
}
#[must_use]
pub fn with_fingerprint_extensions(mut self, extensions: Vec<String>) -> Self {
self.fingerprint_extensions = extensions;
self
}
pub fn process(&self, source_dir: &Path, dest_dir: &Path) -> Result<AssetManifest> {
info!(
source = %source_dir.display(),
dest = %dest_dir.display(),
"processing assets"
);
let mut manifest = AssetManifest::new();
if !source_dir.exists() {
debug!("source directory does not exist, skipping");
return Ok(manifest);
}
self.process_dir(source_dir, source_dir, dest_dir, &mut manifest)?;
info!(count = manifest.assets.len(), "assets processed");
Ok(manifest)
}
fn process_dir(
&self,
base_dir: &Path,
current_dir: &Path,
dest_base: &Path,
manifest: &mut AssetManifest,
) -> Result<()> {
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path
.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
{
continue;
}
if path.is_dir() {
self.process_dir(base_dir, &path, dest_base, manifest)?;
} else if path.is_file() {
self.process_file(base_dir, &path, dest_base, manifest)?;
}
}
Ok(())
}
fn process_file(
&self,
base_dir: &Path,
file_path: &Path,
dest_base: &Path,
manifest: &mut AssetManifest,
) -> Result<()> {
let relative = file_path
.strip_prefix(base_dir)
.map_err(|_| AssetError::InvalidPath(file_path.to_path_buf()))?;
let should_fingerprint = self.fingerprint
&& file_path.extension().is_some_and(|ext| {
self.fingerprint_extensions
.contains(&ext.to_string_lossy().to_string())
});
let dest_relative = if should_fingerprint {
let hash = self.compute_hash(file_path)?;
let stem = file_path.file_stem().unwrap_or_default().to_string_lossy();
let ext = file_path.extension().unwrap_or_default().to_string_lossy();
let fingerprinted_name = format!("{stem}.{hash}.{ext}");
let parent = relative.parent().unwrap_or(Path::new(""));
parent.join(&fingerprinted_name)
} else {
relative.to_path_buf()
};
let dest_path = dest_base.join(&dest_relative);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(file_path, &dest_path)?;
let orig_path = format!("/{}", relative.display()).replace('\\', "/");
let dest_path_str = format!("/{}", dest_relative.display()).replace('\\', "/");
manifest.add(orig_path, dest_path_str);
debug!(
src = %file_path.display(),
dest = %dest_path.display(),
"copied asset"
);
Ok(())
}
fn compute_hash(&self, path: &Path) -> Result<String> {
let mut file = fs::File::open(path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
let mut hash: u64 = 0xcbf29ce484222325;
for byte in &buffer {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
Ok(format!("{hash:016x}")[..12].to_string())
}
pub fn copy_file(source: &Path, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, dest)?;
Ok(())
}
pub fn ensure_dir(path: &Path) -> Result<()> {
if !path.exists() {
fs::create_dir_all(path)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::TempDir;
use super::*;
#[test]
fn test_asset_manifest() {
let mut manifest = AssetManifest::new();
manifest.add("/css/style.css", "/css/style.abc12345.css");
manifest.add("/js/main.js", "/js/main.def67890.js");
assert_eq!(
manifest.get("/css/style.css"),
Some("/css/style.abc12345.css")
);
assert_eq!(manifest.get("/js/main.js"), Some("/js/main.def67890.js"));
assert!(manifest.get("/other.txt").is_none());
}
#[test]
fn test_manifest_to_json() {
let mut manifest = AssetManifest::new();
manifest.add("/style.css", "/style.abc.css");
let json = manifest.to_json();
assert!(json.contains(r#""/style.css": "/style.abc.css""#));
}
#[test]
fn test_process_assets() {
let source = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
let css_path = source.path().join("style.css");
let mut css_file = fs::File::create(&css_path).unwrap();
css_file.write_all(b"body { color: red; }").unwrap();
let txt_path = source.path().join("readme.txt");
let mut txt_file = fs::File::create(&txt_path).unwrap();
txt_file.write_all(b"Hello world").unwrap();
let processor = AssetProcessor::new(false);
let manifest = processor.process(source.path(), dest.path()).unwrap();
assert!(dest.path().join("style.css").exists());
assert!(dest.path().join("readme.txt").exists());
assert_eq!(manifest.assets().len(), 2);
}
#[test]
fn test_process_with_fingerprinting() {
let source = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
let css_path = source.path().join("style.css");
let mut css_file = fs::File::create(&css_path).unwrap();
css_file.write_all(b"body { color: blue; }").unwrap();
let processor = AssetProcessor::new(true);
let manifest = processor.process(source.path(), dest.path()).unwrap();
let fingerprinted = manifest.get("/style.css").unwrap();
assert!(fingerprinted.starts_with("/style."));
assert!(fingerprinted.ends_with(".css"));
assert!(fingerprinted.len() > "/style.css".len());
}
#[test]
fn test_compute_hash_deterministic() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.txt");
let mut file = fs::File::create(&path).unwrap();
file.write_all(b"test content").unwrap();
drop(file);
let processor = AssetProcessor::new(true);
let hash1 = processor.compute_hash(&path).unwrap();
let hash2 = processor.compute_hash(&path).unwrap();
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 12);
}
#[test]
fn test_compute_hash_uniqueness() {
let dir = TempDir::new().unwrap();
let path1 = dir.path().join("file1.txt");
let path2 = dir.path().join("file2.txt");
fs::write(&path1, b"content A").unwrap();
fs::write(&path2, b"content B").unwrap();
let processor = AssetProcessor::new(true);
let hash1 = processor.compute_hash(&path1).unwrap();
let hash2 = processor.compute_hash(&path2).unwrap();
assert_ne!(hash1, hash2);
}
#[test]
fn test_ensure_dir() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a/b/c");
assert!(!nested.exists());
AssetProcessor::ensure_dir(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn test_to_json_valid() {
let mut manifest = AssetManifest::new();
manifest.add("style.css", "style.abc123.css");
manifest.add("script.js", "script.def456.js");
let json = manifest.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed.is_object());
assert_eq!(parsed["style.css"], "style.abc123.css");
assert_eq!(parsed["script.js"], "script.def456.js");
}
#[test]
fn test_to_json_special_chars() {
let mut manifest = AssetManifest::new();
manifest.add("path with spaces.css", "path with spaces.abc.css");
manifest.add("quotes\".js", "quotes\".def.js");
let json = manifest.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["path with spaces.css"], "path with spaces.abc.css");
}
}