webserver_base/assets/
generate.rs1use std::collections::BTreeMap;
21use std::fs::{self, File};
22use std::io::Read;
23use std::path::{Path, PathBuf};
24
25use tracing::{debug, info};
26
27use super::error::CacheBusterError;
28use super::manifest::{Manifest, TYPESCRIPT_MODULE_PATH};
29
30pub const STATIC_DIRECTORY: &str = "static";
36
37pub const FAVICON_DIRECTORY: &str = "static/image/favicon";
39
40pub const FAVICON_SVG_SOURCE: &str = "static/image/favicon/favicon.svg";
42
43pub const FAVICON_PNG_SOURCE: &str = "static/image/favicon/favicon-512.png";
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Phase {
54 NonScripts,
57 Scripts,
60}
61
62impl Phase {
63 #[must_use]
65 pub fn from_subcommand(subcommand: &str) -> Option<Self> {
66 match subcommand {
67 "gen-static-assets" => Some(Self::NonScripts),
68 "gen-static-scripts" => Some(Self::Scripts),
69 _ => None,
70 }
71 }
72
73 fn owns(self, path: &Path) -> bool {
75 let is_script: bool = path.starts_with("static/script");
76 match self {
77 Self::NonScripts => !is_script,
78 Self::Scripts => is_script,
79 }
80 }
81}
82
83pub fn generate_static_assets(phase: Phase) -> Result<(), CacheBusterError> {
91 let root: &Path = Path::new(STATIC_DIRECTORY);
92 if !root.is_dir() {
93 return Err(CacheBusterError::MissingStaticDirectory {
94 path: root.to_path_buf(),
95 });
96 }
97
98 let mut manifest: Manifest = Manifest::load_or_empty()?;
101
102 if phase == Phase::NonScripts {
103 super::icons::generate_missing_icons(&manifest)?;
104 }
105
106 manifest.extend(hash_tree(root, phase)?);
107 manifest.write_json()?;
108
109 if phase == Phase::NonScripts {
110 manifest.write_typescript()?;
111 info!(
112 "hashed {} static asset(s); wrote the manifest and {TYPESCRIPT_MODULE_PATH}",
113 manifest.len()
114 );
115 } else {
116 info!(
117 "hashed the built scripts; manifest now holds {} entries",
118 manifest.len()
119 );
120 }
121
122 Ok(())
123}
124
125fn hash_tree(root: &Path, phase: Phase) -> Result<BTreeMap<String, String>, CacheBusterError> {
128 let mut cache: BTreeMap<String, String> = BTreeMap::new();
129 let mut directories: Vec<PathBuf> = vec![root.to_path_buf()];
130
131 while let Some(directory) = directories.pop() {
132 let entries =
133 fs::read_dir(&directory).map_err(|source| CacheBusterError::ReadDirectory {
134 path: directory.clone(),
135 source,
136 })?;
137
138 for entry in entries {
139 let entry = entry.map_err(|source| CacheBusterError::ReadDirectory {
140 path: directory.clone(),
141 source,
142 })?;
143 let path: PathBuf = entry.path();
144
145 if path.is_dir() {
146 directories.push(path);
147 continue;
148 }
149 if !phase.owns(&path) {
150 continue;
151 }
152 if is_content_hashed(&path) {
156 debug!("`{}` is already hashed; leaving it alone", path.display());
157 continue;
158 }
159
160 let hashed: PathBuf = content_hashed_path(&path, root)?;
161 fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
162 from: path.clone(),
163 to: hashed.clone(),
164 source,
165 })?;
166
167 cache.insert(
168 path.to_string_lossy().to_string(),
169 hashed.to_string_lossy().to_string(),
170 );
171 }
172 }
173
174 Ok(cache)
175}
176
177fn is_content_hashed(path: &Path) -> bool {
179 path.file_name()
180 .and_then(|name| name.to_str())
181 .is_some_and(|name| {
182 name.split('.').any(|segment| {
183 segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
184 })
185 })
186}
187
188fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
191 let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
192 path: file_path.to_path_buf(),
193 source,
194 })?;
195 let mut contents: Vec<u8> = Vec::new();
196 file.read_to_end(&mut contents)
197 .map_err(|source| CacheBusterError::ReadFile {
198 path: file_path.to_path_buf(),
199 source,
200 })?;
201
202 let hash: String = format!("{:x}", md5::compute(contents));
203
204 let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
205 let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
206 let name: &str = relative
207 .file_name()
208 .and_then(|name| name.to_str())
209 .unwrap_or_default();
210
211 let hashed_name: String = match name.split_once('.') {
212 Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
213 None => format!("{name}.{hash}"),
214 };
215
216 Ok(root.join(parent).join(hashed_name))
217}
218
219#[cfg(test)]
220mod tests {
221 use std::path::{Path, PathBuf};
222
223 use super::{Phase, is_content_hashed};
224
225 #[test]
226 fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
227 let hashed: PathBuf =
228 PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
229
230 let expected: bool = true;
231 let actual: bool = is_content_hashed(&hashed);
232 assert_eq!(expected, actual);
233 }
234
235 #[test]
236 fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
237 let expected: bool = false;
238 let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
239 assert_eq!(expected, actual);
240 }
241
242 #[test]
243 fn a_long_but_non_hex_segment_is_not_a_hash() {
244 let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
246
247 let expected: bool = false;
248 let actual: bool = is_content_hashed(&path);
249 assert_eq!(expected, actual);
250 }
251
252 #[test]
253 fn a_source_map_keeps_its_double_extension() {
254 let hashed: PathBuf =
255 super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
256 let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
257
258 assert!(name.starts_with("Cargo."));
261 assert!(
262 std::path::Path::new(name)
263 .extension()
264 .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
265 );
266 }
267
268 #[test]
269 fn each_phase_owns_a_disjoint_half_of_the_tree() {
270 let script: &Path = Path::new("static/script/main.js");
271 let image: &Path = Path::new("static/image/social/card.webp");
272
273 assert!(!Phase::NonScripts.owns(script));
274 assert!(Phase::NonScripts.owns(image));
275 assert!(Phase::Scripts.owns(script));
276 assert!(!Phase::Scripts.owns(image));
277 }
278
279 #[test]
280 fn the_subcommands_map_to_their_phases() {
281 assert_eq!(
282 Some(Phase::NonScripts),
283 Phase::from_subcommand("gen-static-assets")
284 );
285 assert_eq!(
286 Some(Phase::Scripts),
287 Phase::from_subcommand("gen-static-scripts")
288 );
289 assert_eq!(None, Phase::from_subcommand("serve"));
290 }
291}