use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
use super::error::CacheBusterError;
use super::manifest::{Manifest, TYPESCRIPT_MODULE_PATH};
pub const STATIC_DIRECTORY: &str = "static";
pub const FAVICON_DIRECTORY: &str = "static/image/favicon";
pub const FAVICON_SVG_SOURCE: &str = "static/image/favicon/favicon.svg";
pub const FAVICON_PNG_SOURCE: &str = "static/image/favicon/favicon-512.png";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
NonScripts,
Scripts,
}
impl Phase {
#[must_use]
pub fn from_subcommand(subcommand: &str) -> Option<Self> {
match subcommand {
"gen-static-assets" => Some(Self::NonScripts),
"gen-static-scripts" => Some(Self::Scripts),
_ => None,
}
}
fn owns(self, path: &Path) -> bool {
let is_script: bool = path.starts_with("static/script");
match self {
Self::NonScripts => !is_script,
Self::Scripts => is_script,
}
}
}
pub fn generate_static_assets(phase: Phase) -> Result<(), CacheBusterError> {
generate_static_assets_in(Path::new(""), phase)
}
pub(crate) fn generate_static_assets_in(root: &Path, phase: Phase) -> Result<(), CacheBusterError> {
let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
if !static_directory.is_dir() {
return Err(CacheBusterError::MissingStaticDirectory {
path: static_directory,
});
}
let mut manifest: Manifest = Manifest::load_or_empty_in(root)?;
if phase == Phase::NonScripts {
super::icons::generate_missing_icons_in(root, &manifest)?;
}
manifest.extend(hash_tree(root, phase)?);
manifest.write_json_in(root)?;
if phase == Phase::NonScripts {
manifest.write_typescript_in(root)?;
info!(
"hashed {} static asset(s); wrote the manifest and {TYPESCRIPT_MODULE_PATH}",
manifest.len()
);
} else {
info!(
"hashed the built scripts; manifest now holds {} entries",
manifest.len()
);
}
Ok(())
}
fn hash_tree(root: &Path, phase: Phase) -> Result<BTreeMap<String, String>, CacheBusterError> {
let static_directory: PathBuf = root.join(STATIC_DIRECTORY);
let mut cache: BTreeMap<String, String> = BTreeMap::new();
let mut directories: Vec<PathBuf> = vec![static_directory.clone()];
while let Some(directory) = directories.pop() {
let entries =
fs::read_dir(&directory).map_err(|source| CacheBusterError::ReadDirectory {
path: directory.clone(),
source,
})?;
for entry in entries {
let entry: std::fs::DirEntry =
entry.map_err(|source| CacheBusterError::ReadDirectory {
path: directory.clone(),
source,
})?;
let path: PathBuf = entry.path();
if path.is_dir() {
directories.push(path);
continue;
}
let relative: &Path = path.strip_prefix(root).unwrap_or(&path);
if !phase.owns(relative) {
continue;
}
if is_content_hashed(&path) {
debug!("`{}` is already hashed; leaving it alone", path.display());
continue;
}
let hashed: PathBuf = content_hashed_path(&path, &static_directory)?;
fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
from: path.clone(),
to: hashed.clone(),
source,
})?;
cache.insert(
relative.to_string_lossy().to_string(),
hashed
.strip_prefix(root)
.unwrap_or(&hashed)
.to_string_lossy()
.to_string(),
);
}
}
Ok(cache)
}
fn is_content_hashed(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.split('.').any(|segment| {
segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
})
})
}
fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
path: file_path.to_path_buf(),
source,
})?;
let mut contents: Vec<u8> = Vec::new();
file.read_to_end(&mut contents)
.map_err(|source| CacheBusterError::ReadFile {
path: file_path.to_path_buf(),
source,
})?;
let hash: String = format!("{:x}", md5::compute(contents));
let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
let name: &str = relative
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
let hashed_name: String = match name.split_once('.') {
Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
None => format!("{name}.{hash}"),
};
Ok(root.join(parent).join(hashed_name))
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use super::{Phase, is_content_hashed};
#[test]
fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
let hashed: PathBuf =
PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
let expected: bool = true;
let actual: bool = is_content_hashed(&hashed);
assert_eq!(expected, actual);
}
#[test]
fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
let expected: bool = false;
let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
assert_eq!(expected, actual);
}
#[test]
fn a_long_but_non_hex_segment_is_not_a_hash() {
let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
let expected: bool = false;
let actual: bool = is_content_hashed(&path);
assert_eq!(expected, actual);
}
#[test]
fn a_source_map_keeps_its_double_extension() {
let hashed: PathBuf =
super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
assert!(name.starts_with("Cargo."));
assert!(
std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
);
}
#[test]
fn each_phase_owns_a_disjoint_half_of_the_tree() {
let script: &Path = Path::new("static/script/main.js");
let image: &Path = Path::new("static/image/social/card.webp");
assert!(!Phase::NonScripts.owns(script));
assert!(Phase::NonScripts.owns(image));
assert!(Phase::Scripts.owns(script));
assert!(!Phase::Scripts.owns(image));
}
#[test]
fn the_subcommands_map_to_their_phases() {
assert_eq!(
Some(Phase::NonScripts),
Phase::from_subcommand("gen-static-assets")
);
assert_eq!(
Some(Phase::Scripts),
Phase::from_subcommand("gen-static-scripts")
);
assert_eq!(None, Phase::from_subcommand("serve"));
}
}