use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const MANIFEST_VERSION: u32 = 1;
pub const DEFAULT_S_MAXAGE: u32 = 60;
pub const DEFAULT_SWR: u32 = 86_400;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachePolicy {
pub s_maxage: u32,
pub swr: u32,
}
impl Default for CachePolicy {
fn default() -> Self {
Self {
s_maxage: DEFAULT_S_MAXAGE,
swr: DEFAULT_SWR,
}
}
}
impl CachePolicy {
#[must_use]
pub fn to_cache_control(&self) -> String {
format!(
"s-maxage={}, stale-while-revalidate={}",
self.s_maxage, self.swr
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
pub sources: Vec<String>,
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache: Option<CachePolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
pub generated_at: String,
pub default_cache: CachePolicy,
pub entries: BTreeMap<String, ManifestEntry>,
}
impl Default for Manifest {
fn default() -> Self {
Self::new("unspecified")
}
}
impl Manifest {
#[must_use]
pub fn new(build_id: impl Into<String>) -> Self {
Self {
version: MANIFEST_VERSION,
generated_at: build_id.into(),
default_cache: CachePolicy::default(),
entries: BTreeMap::new(),
}
}
pub fn insert(&mut self, url: impl Into<String>, entry: ManifestEntry) {
let _ = self.entries.insert(url.into(), entry);
}
#[must_use]
pub fn get(&self, url: &str) -> Option<&ManifestEntry> {
self.entries.get(url)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn to_pretty_json(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
#[must_use]
pub fn urls_for_source(&self, source_key: &str) -> Vec<String> {
self.entries
.iter()
.filter_map(|(url, entry)| {
if entry.sources.iter().any(|s| s == source_key) {
Some(url.clone())
} else {
None
}
})
.collect()
}
}
#[must_use]
pub fn hash_sources(sources: &[&[u8]]) -> String {
let mut hasher = Sha256::new();
for (i, src) in sources.iter().enumerate() {
if i > 0 {
hasher.update([0u8]);
}
hasher.update(src);
}
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ =
std::fmt::Write::write_fmt(&mut out, format_args!("{byte:02x}"));
}
out
}
#[must_use]
pub fn build_entry(
sources: Vec<String>,
bytes: &[&[u8]],
cache: Option<CachePolicy>,
) -> ManifestEntry {
debug_assert_eq!(
sources.len(),
bytes.len(),
"sources and bytes must align"
);
let hash = hash_sources(bytes);
ManifestEntry {
sources,
hash,
cache,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn manifest_round_trip_json() {
let mut m = Manifest::new("build-42");
m.insert(
"/index.html",
ManifestEntry {
sources: vec![
"content/index.md".into(),
"templates/base.html".into(),
],
hash: hash_sources(&[b"# Home", b"<html></html>"]),
cache: None,
},
);
m.insert(
"/posts/foo/index.html",
ManifestEntry {
sources: vec![
"content/posts/foo.md".into(),
"templates/post.html".into(),
],
hash: hash_sources(&[b"# Foo", b"<html><body/></html>"]),
cache: Some(CachePolicy {
s_maxage: 600,
swr: 3600,
}),
},
);
let json = m.to_pretty_json().unwrap();
let i_idx = json.find("/index.html").unwrap();
let p_idx = json.find("/posts/foo/index.html").unwrap();
assert!(i_idx < p_idx, "URLs must be lexicographically ordered");
let parsed: Manifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, m);
}
#[test]
fn cache_policy_to_cache_control() {
let p = CachePolicy {
s_maxage: 120,
swr: 600,
};
assert_eq!(
p.to_cache_control(),
"s-maxage=120, stale-while-revalidate=600"
);
}
#[test]
fn cache_policy_default_matches_constants() {
let d = CachePolicy::default();
assert_eq!(d.s_maxage, DEFAULT_S_MAXAGE);
assert_eq!(d.swr, DEFAULT_SWR);
}
#[test]
fn hash_sources_is_order_sensitive() {
let a = hash_sources(&[b"hello", b"world"]);
let b = hash_sources(&[b"world", b"hello"]);
assert_ne!(a, b);
}
#[test]
fn hash_sources_avoids_concat_collision() {
let a = hash_sources(&[b"ab", b"c"]);
let b = hash_sources(&[b"a", b"bc"]);
assert_ne!(a, b);
}
#[test]
fn hash_sources_stable_for_same_input() {
let a = hash_sources(&[b"foo", b"bar"]);
let b = hash_sources(&[b"foo", b"bar"]);
assert_eq!(a, b);
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn build_entry_populates_hash() {
let entry = build_entry(
vec!["a".into(), "b".into()],
&[b"alpha", b"beta"],
None,
);
assert_eq!(entry.sources, vec!["a", "b"]);
assert!(entry.cache.is_none());
assert_eq!(entry.hash.len(), 64);
}
#[test]
fn manifest_get_returns_entry() {
let mut m = Manifest::default();
let entry = build_entry(vec!["x".into()], &[b"xxx"], None);
m.insert("/x.html", entry.clone());
assert_eq!(m.get("/x.html"), Some(&entry));
assert!(m.get("/missing").is_none());
}
#[test]
fn manifest_len_and_is_empty() {
let mut m = Manifest::new("b");
assert!(m.is_empty());
assert_eq!(m.len(), 0);
m.insert("/a.html", build_entry(vec!["a".into()], &[b"a"], None));
assert!(!m.is_empty());
assert_eq!(m.len(), 1);
}
#[test]
fn urls_for_source_finds_dependents() {
let mut m = Manifest::default();
m.insert(
"/a.html",
build_entry(
vec!["c/a.md".into(), "t/base.html".into()],
&[b"A", b"T"],
None,
),
);
m.insert(
"/b.html",
build_entry(
vec!["c/b.md".into(), "t/base.html".into()],
&[b"B", b"T"],
None,
),
);
m.insert(
"/tags/foo.html",
build_entry(
vec!["c/a.md".into(), "c/b.md".into(), "t/tag.html".into()],
&[b"A", b"B", b"TAG"],
None,
),
);
let mut deps = m.urls_for_source("c/a.md");
deps.sort();
assert_eq!(deps, vec!["/a.html", "/tags/foo.html"]);
let mut tdeps = m.urls_for_source("t/base.html");
tdeps.sort();
assert_eq!(tdeps, vec!["/a.html", "/b.html"]);
let none = m.urls_for_source("unknown.md");
assert!(none.is_empty());
}
#[test]
fn entry_cache_skipped_when_none() {
let mut m = Manifest::default();
m.insert("/a.html", build_entry(vec!["a.md".into()], &[b"a"], None));
let json = m.to_pretty_json().unwrap();
assert!(!json.contains("\"cache\""));
}
#[test]
fn entry_cache_emitted_when_some() {
let mut m = Manifest::default();
m.insert(
"/a.html",
build_entry(
vec!["a.md".into()],
&[b"a"],
Some(CachePolicy {
s_maxage: 10,
swr: 20,
}),
),
);
let json = m.to_pretty_json().unwrap();
assert!(json.contains("\"cache\""));
assert!(json.contains("\"s_maxage\": 10"));
assert!(json.contains("\"swr\": 20"));
}
#[test]
fn manifest_version_is_one() {
let m = Manifest::new("x");
assert_eq!(m.version, 1);
}
}