use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use super::error::CacheBusterError;
pub const MANIFEST_PATH: &str = "cache-buster.json";
pub const TYPESCRIPT_MODULE_PATH: &str = "static/script/generated/cache-buster.ts";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Manifest {
entries: BTreeMap<String, String>,
}
impl Manifest {
pub fn load_or_empty() -> Result<Self, CacheBusterError> {
Self::load_or_empty_in(Path::new(""))
}
pub(crate) fn load_or_empty_in(root: &Path) -> Result<Self, CacheBusterError> {
if !root.join(MANIFEST_PATH).is_file() {
return Ok(Self::default());
}
Self::load_in(root)
}
pub fn load() -> Result<Self, CacheBusterError> {
Self::load_in(Path::new(""))
}
pub(crate) fn load_in(root: &Path) -> Result<Self, CacheBusterError> {
let path: PathBuf = root.join(MANIFEST_PATH);
let contents: String =
std::fs::read_to_string(&path).map_err(|source| CacheBusterError::ReadFile {
path: path.clone(),
source,
})?;
let entries: BTreeMap<String, String> = serde_json::from_str(&contents)
.map_err(|source| CacheBusterError::ParseManifest { path, source })?;
Ok(Self { entries })
}
pub fn extend(&mut self, entries: BTreeMap<String, String>) {
self.entries.extend(entries);
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub const fn entries(&self) -> &BTreeMap<String, String> {
&self.entries
}
#[must_use]
pub fn into_entries(self) -> BTreeMap<String, String> {
self.entries
}
#[must_use]
pub fn resolve<'a>(&'a self, original: &'a str) -> &'a str {
let key: &str = original.trim_start_matches('/');
self.entries.get(key).map_or(original, String::as_str)
}
#[must_use]
pub fn contains(&self, original: &str) -> bool {
self.entries.contains_key(original.trim_start_matches('/'))
}
pub fn write_json(&self) -> Result<(), CacheBusterError> {
self.write_json_in(Path::new(""))
}
pub(crate) fn write_json_in(&self, root: &Path) -> Result<(), CacheBusterError> {
let path: PathBuf = root.join(MANIFEST_PATH);
let file: File = File::create(&path).map_err(|source| CacheBusterError::WriteManifest {
path: path.clone(),
source,
})?;
serde_json::to_writer_pretty(BufWriter::new(file), &self.entries).map_err(|source| {
CacheBusterError::WriteManifest {
path,
source: std::io::Error::other(source),
}
})
}
pub fn write_typescript(&self) -> Result<(), CacheBusterError> {
self.write_typescript_in(Path::new(""))
}
pub(crate) fn write_typescript_in(&self, root: &Path) -> Result<(), CacheBusterError> {
let path: PathBuf = root.join(TYPESCRIPT_MODULE_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| CacheBusterError::WriteManifest {
path: parent.to_path_buf(),
source,
})?;
}
let mut source: String = String::from(
"// Generated by `gen_static_assets`. Do not edit, do not commit.\n\n\
/**\n\
\x20* Maps a logical static-asset path to its content-hashed one. The build\n\
\x20* inlines this, so resolving an asset costs the browser nothing at\n\
\x20* runtime.\n\
\x20*/\n\
export const CACHE_BUSTER = {\n",
);
for (original, hashed) in &self.entries {
let _ = writeln!(source, " {original:?}: {hashed:?},");
}
source.push_str(
"} as const;\n\n\
/** Every asset path the build knows about. */\n\
export type CacheBustedPath = keyof typeof CACHE_BUSTER;\n\n\
/** Resolves a static asset to its root-absolute, hashed URL. */\n\
export function asset(path: CacheBustedPath): string {\n\
\x20 return `/${CACHE_BUSTER[path]}`;\n\
}\n",
);
std::fs::write(&path, source)
.map_err(|source| CacheBusterError::WriteManifest { path, source })
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::Manifest;
fn manifest() -> Manifest {
let mut entries: BTreeMap<String, String> = BTreeMap::new();
entries.insert(
String::from("static/stylesheet/main.css"),
String::from("static/stylesheet/main.abc123.css"),
);
Manifest { entries }
}
#[test]
fn a_known_asset_resolves_to_its_hashed_path() {
let manifest: Manifest = manifest();
let expected: &str = "static/stylesheet/main.abc123.css";
let actual: &str = manifest.resolve("static/stylesheet/main.css");
assert_eq!(expected, actual);
}
#[test]
fn a_leading_slash_still_resolves() {
let manifest: Manifest = manifest();
let expected: &str = "static/stylesheet/main.abc123.css";
let actual: &str = manifest.resolve("/static/stylesheet/main.css");
assert_eq!(expected, actual);
}
#[test]
fn an_unknown_asset_is_returned_unchanged() {
let manifest: Manifest = manifest();
let expected: &str = "https://cdn.example.com/a.css";
let actual: &str = manifest.resolve("https://cdn.example.com/a.css");
assert_eq!(expected, actual);
}
#[test]
fn extending_replaces_an_existing_entry_rather_than_duplicating_it() {
let mut manifest: Manifest = manifest();
let mut second: BTreeMap<String, String> = BTreeMap::new();
second.insert(
String::from("static/stylesheet/main.css"),
String::from("static/stylesheet/main.def456.css"),
);
manifest.extend(second);
let expected: usize = 1;
let actual: usize = manifest.len();
assert_eq!(expected, actual);
let expected: &str = "static/stylesheet/main.def456.css";
let actual: &str = manifest.resolve("static/stylesheet/main.css");
assert_eq!(expected, actual);
}
}