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: std::fs::DirEntry =
140 entry.map_err(|source| CacheBusterError::ReadDirectory {
141 path: directory.clone(),
142 source,
143 })?;
144 let path: PathBuf = entry.path();
145
146 if path.is_dir() {
147 directories.push(path);
148 continue;
149 }
150 if !phase.owns(&path) {
151 continue;
152 }
153 if is_content_hashed(&path) {
157 debug!("`{}` is already hashed; leaving it alone", path.display());
158 continue;
159 }
160
161 let hashed: PathBuf = content_hashed_path(&path, root)?;
162 fs::rename(&path, &hashed).map_err(|source| CacheBusterError::Rename {
163 from: path.clone(),
164 to: hashed.clone(),
165 source,
166 })?;
167
168 cache.insert(
169 path.to_string_lossy().to_string(),
170 hashed.to_string_lossy().to_string(),
171 );
172 }
173 }
174
175 Ok(cache)
176}
177
178fn is_content_hashed(path: &Path) -> bool {
180 path.file_name()
181 .and_then(|name| name.to_str())
182 .is_some_and(|name| {
183 name.split('.').any(|segment| {
184 segment.len() == 32 && segment.bytes().all(|byte| byte.is_ascii_hexdigit())
185 })
186 })
187}
188
189fn content_hashed_path(file_path: &Path, root: &Path) -> Result<PathBuf, CacheBusterError> {
192 let mut file: File = File::open(file_path).map_err(|source| CacheBusterError::ReadFile {
193 path: file_path.to_path_buf(),
194 source,
195 })?;
196 let mut contents: Vec<u8> = Vec::new();
197 file.read_to_end(&mut contents)
198 .map_err(|source| CacheBusterError::ReadFile {
199 path: file_path.to_path_buf(),
200 source,
201 })?;
202
203 let hash: String = format!("{:x}", md5::compute(contents));
204
205 let relative: &Path = file_path.strip_prefix(root).unwrap_or(file_path);
206 let parent: &Path = relative.parent().unwrap_or_else(|| Path::new(""));
207 let name: &str = relative
208 .file_name()
209 .and_then(|name| name.to_str())
210 .unwrap_or_default();
211
212 let hashed_name: String = match name.split_once('.') {
213 Some((stem, extension)) => format!("{stem}.{hash}.{extension}"),
214 None => format!("{name}.{hash}"),
215 };
216
217 Ok(root.join(parent).join(hashed_name))
218}
219
220#[cfg(test)]
221mod tests {
222 use std::path::{Path, PathBuf};
223
224 use super::{Phase, is_content_hashed};
225
226 #[test]
227 fn an_already_hashed_file_is_recognised_so_it_is_never_hashed_twice() {
228 let hashed: PathBuf =
229 PathBuf::from("static/stylesheet/main.aa676972bbd2b68e94ef8e91e81d20be.css");
230
231 let expected: bool = true;
232 let actual: bool = is_content_hashed(&hashed);
233 assert_eq!(expected, actual);
234 }
235
236 #[test]
237 fn a_plain_file_is_not_mistaken_for_a_hashed_one() {
238 let expected: bool = false;
239 let actual: bool = is_content_hashed(Path::new("static/stylesheet/main.css"));
240 assert_eq!(expected, actual);
241 }
242
243 #[test]
244 fn a_long_but_non_hex_segment_is_not_a_hash() {
245 let path: PathBuf = PathBuf::from("static/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz.css");
247
248 let expected: bool = false;
249 let actual: bool = is_content_hashed(&path);
250 assert_eq!(expected, actual);
251 }
252
253 #[test]
254 fn a_source_map_keeps_its_double_extension() {
255 let hashed: PathBuf =
256 super::content_hashed_path(Path::new("Cargo.toml"), Path::new(".")).expect("readable");
257 let name: &str = hashed.file_name().and_then(|n| n.to_str()).expect("named");
258
259 assert!(name.starts_with("Cargo."));
262 assert!(
263 std::path::Path::new(name)
264 .extension()
265 .is_some_and(|extension| extension.eq_ignore_ascii_case("toml"))
266 );
267 }
268
269 #[test]
270 fn each_phase_owns_a_disjoint_half_of_the_tree() {
271 let script: &Path = Path::new("static/script/main.js");
272 let image: &Path = Path::new("static/image/social/card.webp");
273
274 assert!(!Phase::NonScripts.owns(script));
275 assert!(Phase::NonScripts.owns(image));
276 assert!(Phase::Scripts.owns(script));
277 assert!(!Phase::Scripts.owns(image));
278 }
279
280 #[test]
281 fn the_subcommands_map_to_their_phases() {
282 assert_eq!(
283 Some(Phase::NonScripts),
284 Phase::from_subcommand("gen-static-assets")
285 );
286 assert_eq!(
287 Some(Phase::Scripts),
288 Phase::from_subcommand("gen-static-scripts")
289 );
290 assert_eq!(None, Phase::from_subcommand("serve"));
291 }
292}