1use super::helpers::{read_meta_sidecars, truncate_at_word};
7use crate::error::{PathErrorExt, SsgError};
8use crate::plugin::{Plugin, PluginContext};
9use std::fs;
10
11#[derive(Debug, Clone, Copy)]
14pub struct ManifestFixPlugin;
15
16impl Plugin for ManifestFixPlugin {
17 fn name(&self) -> &'static str {
18 "manifest-fix"
19 }
20
21 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
22 let manifest_path = ctx.site_dir.join("manifest.json");
23 if !manifest_path.exists() {
24 return Ok(());
25 }
26
27 let content =
28 fs::read_to_string(&manifest_path).with_path(&manifest_path)?;
29
30 if content.trim().is_empty() {
31 let meta_entries =
32 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
33 let mut manifest = serde_json::json!({
34 "name": "Static Site",
35 "short_name": "Site",
36 "start_url": "/",
37 "display": "standalone"
38 });
39 if let Some(desc) = find_full_description(&meta_entries) {
40 let truncated = truncate_at_word(&desc, 200);
41 manifest["description"] = serde_json::Value::String(truncated);
42 }
43 let output = serialize_manifest(&manifest)
44 .map_err(|e| SsgError::io(e, &manifest_path))?;
45 fs::write(&manifest_path, output).with_path(&manifest_path)?;
46 log::info!("[manifest-fix] Generated manifest.json from metadata");
47 return Ok(());
48 }
49
50 let mut manifest: serde_json::Value = serde_json::from_str(&content)
51 .map_err(|e| SsgError::io(e, &manifest_path))?;
52
53 let meta_entries =
54 read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
55
56 let full_description = find_full_description(&meta_entries);
57
58 if let Some(desc) = full_description {
59 let truncated = truncate_at_word(&desc, 200);
60 manifest["description"] = serde_json::Value::String(truncated);
61 } else if let Some(current) =
62 manifest.get("description").and_then(|v| v.as_str())
63 {
64 if let Some(fixed) = fix_truncated_description(current) {
65 manifest["description"] = serde_json::Value::String(fixed);
66 }
67 }
68
69 drop_empty_icons(&mut manifest);
73
74 let output = serialize_manifest(&manifest)
75 .map_err(|e| SsgError::io(e, &manifest_path))?;
76 fs::write(&manifest_path, output).with_path(&manifest_path)?;
77
78 log::info!("[manifest-fix] Fixed manifest.json description");
79 Ok(())
80 }
81}
82
83fn find_full_description(
85 meta_entries: &[(String, std::collections::HashMap<String, String>)],
86) -> Option<String> {
87 meta_entries
88 .iter()
89 .find(|(rel, _)| rel.is_empty() || rel == ".")
90 .and_then(|(_, meta)| meta.get("description"))
91 .or_else(|| {
92 meta_entries
93 .iter()
94 .find_map(|(_, meta)| meta.get("description"))
95 })
96 .cloned()
97}
98
99fn drop_empty_icons(manifest: &mut serde_json::Value) {
103 let Some(icons) = manifest.get_mut("icons").and_then(|v| v.as_array_mut())
104 else {
105 return;
106 };
107 icons.retain(|icon| {
108 icon.get("src")
109 .and_then(|s| s.as_str())
110 .is_some_and(|s| !s.is_empty())
111 });
112 if icons.is_empty() {
113 let _ = manifest.as_object_mut().and_then(|map| map.remove("icons"));
118 }
119}
120
121fn serialize_manifest(
125 manifest: &serde_json::Value,
126) -> serde_json::Result<String> {
127 fail_point!("postprocess::manifest-serialize", |_| Err(
128 <serde_json::Error as serde::ser::Error>::custom(
129 "injected: postprocess::manifest-serialize"
130 )
131 ));
132 serde_json::to_string_pretty(manifest)
133}
134
135fn fix_truncated_description(current: &str) -> Option<String> {
138 if current.ends_with('.')
139 || current.ends_with('!')
140 || current.ends_with('?')
141 || current.ends_with("...")
142 {
143 return None;
144 }
145 Some(if let Some(last_space) = current.rfind(' ') {
146 format!("{}...", ¤t[..last_space])
147 } else {
148 format!("{current}...")
149 })
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::plugin::PluginContext;
156 use anyhow::Result;
157 use std::path::Path;
158 use tempfile::tempdir;
159
160 fn test_ctx(site_dir: &Path) -> PluginContext {
161 crate::test_support::init_logger();
162 PluginContext::new(
163 Path::new("content"),
164 Path::new("build"),
165 site_dir,
166 Path::new("templates"),
167 )
168 }
169
170 #[test]
171 fn test_drop_empty_icons_removes_empty_src() {
172 let mut m: serde_json::Value = serde_json::from_str(
173 r#"{"icons":[{"src":"","sizes":"512x512"},{"src":"/icon.svg","sizes":"512x512"}]}"#,
174 )
175 .unwrap();
176 drop_empty_icons(&mut m);
177 let icons = m["icons"].as_array().unwrap();
178 assert_eq!(icons.len(), 1);
179 assert_eq!(icons[0]["src"], "/icon.svg");
180 }
181
182 #[test]
183 fn test_drop_empty_icons_removes_key_when_all_empty() {
184 let mut m: serde_json::Value =
185 serde_json::from_str(r#"{"name":"x","icons":[{"src":""}]}"#)
186 .unwrap();
187 drop_empty_icons(&mut m);
188 assert!(m.get("icons").is_none(), "icons key should be dropped");
189 }
190
191 #[test]
192 fn name_is_stable() {
193 assert_eq!(ManifestFixPlugin.name(), "manifest-fix");
194 }
195
196 #[test]
197 #[serial_test::parallel]
198 fn after_compile_no_op_when_manifest_missing() -> Result<()> {
199 let tmp = tempdir().unwrap();
200 let ctx = test_ctx(tmp.path());
201 ManifestFixPlugin.after_compile(&ctx).unwrap();
202 assert!(!tmp.path().join("manifest.json").exists());
203 Ok(())
204 }
205
206 #[test]
207 #[serial_test::parallel]
208 fn after_compile_returns_error_on_invalid_json() {
209 let tmp = tempdir().unwrap();
210 fs::write(tmp.path().join("manifest.json"), "not valid json").unwrap();
211 let ctx = test_ctx(tmp.path());
212 let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
213 assert!(
214 err.to_string().contains("invalid JSON")
215 || err.to_string().contains("manifest"),
216 "expected JSON parse error, got: {err}"
217 );
218 }
219
220 #[test]
221 fn drop_empty_icons_keeps_array_with_real_entries() {
222 let mut m: serde_json::Value = serde_json::from_str(
223 r#"{"icons":[{"src":"/a.svg"},{"src":"/b.svg"}]}"#,
224 )
225 .unwrap();
226 drop_empty_icons(&mut m);
227 let icons = m["icons"].as_array().unwrap();
228 assert_eq!(icons.len(), 2);
229 }
230
231 #[test]
232 fn drop_empty_icons_no_op_when_no_icons_key() {
233 let mut m: serde_json::Value =
234 serde_json::from_str(r#"{"name":"x"}"#).unwrap();
235 drop_empty_icons(&mut m);
236 assert!(m.get("icons").is_none());
237 assert_eq!(m["name"], "x");
238 }
239
240 #[test]
241 fn drop_empty_icons_no_op_when_icons_not_array() {
242 let mut m: serde_json::Value =
244 serde_json::from_str(r#"{"icons":"not an array"}"#).unwrap();
245 drop_empty_icons(&mut m);
246 assert_eq!(m["icons"], "not an array");
247 }
248
249 #[test]
250 fn fix_truncated_description_returns_none_when_already_terminated() {
251 assert!(fix_truncated_description("ends with period.").is_none());
252 assert!(fix_truncated_description("ends with bang!").is_none());
253 assert!(fix_truncated_description("ends with question?").is_none());
254 assert!(fix_truncated_description("ends with ellipsis...").is_none());
255 }
256
257 #[test]
258 fn fix_truncated_description_truncates_at_word_boundary() {
259 let out =
260 fix_truncated_description("a long description without ending");
261 assert_eq!(out.as_deref(), Some("a long description without..."));
262 }
263
264 #[test]
265 fn fix_truncated_description_no_space_appends_ellipsis() {
266 let out = fix_truncated_description("supercalifragilistic");
268 assert_eq!(out.as_deref(), Some("supercalifragilistic..."));
269 }
270
271 #[test]
272 #[serial_test::parallel]
273 fn after_compile_drops_empty_icons_in_manifest() -> Result<()> {
274 let tmp = tempdir().unwrap();
275 let manifest_path = tmp.path().join("manifest.json");
276 fs::write(
277 &manifest_path,
278 r#"{"name":"X","description":"Already terminated.","icons":[{"src":""}]}"#,
279 ).unwrap();
280 let ctx = test_ctx(tmp.path());
281 ManifestFixPlugin.after_compile(&ctx).unwrap();
282 let after: serde_json::Value =
283 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
284 .unwrap();
285 assert!(after.get("icons").is_none(), "empty icon should be dropped");
286 Ok(())
287 }
288
289 #[test]
290 #[serial_test::parallel]
291 fn test_manifest_fix_repairs_truncated_description() -> Result<()> {
292 let tmp = tempdir().unwrap();
293 let manifest_path = tmp.path().join("manifest.json");
294 fs::write(
295 &manifest_path,
296 r#"{"name":"Test","description":"A new paper suggests Shor's algorithm could run on as few as 10,000 qubits. The threshold for cryptographically relevant"}"#,
297 ).unwrap();
298
299 let ctx = test_ctx(tmp.path());
300 ManifestFixPlugin.after_compile(&ctx).unwrap();
301
302 let result = fs::read_to_string(&manifest_path).unwrap();
303 let manifest: serde_json::Value =
304 serde_json::from_str(&result).unwrap();
305 let desc = manifest["description"].as_str().unwrap();
306 let clean =
308 desc.ends_with("...") | desc.ends_with('.') | desc.ends_with('!');
309 assert!(clean, "Description should end cleanly, got: {desc}");
310 Ok(())
311 }
312
313 #[test]
314 #[serial_test::parallel]
315 fn test_manifest_fix_uses_sidecar_description() -> Result<()> {
316 let tmp = tempdir().unwrap();
317 let manifest_path = tmp.path().join("manifest.json");
318 fs::write(
319 &manifest_path,
320 r#"{"name":"Test","description":"Short description"}"#,
321 )
322 .unwrap();
323 fs::write(
324 tmp.path().join("index.meta.json"),
325 r#"{"description":"This is a very long description that we are using to test manifest metadata sidecar description truncation logic in the manifest fix plugin. We need to make sure that the total length of this text exceeds two hundred characters so that the truncation is triggered."}"#,
326 ).unwrap();
327
328 let ctx = test_ctx(tmp.path());
329 ManifestFixPlugin.after_compile(&ctx).unwrap();
330
331 let result = fs::read_to_string(&manifest_path).unwrap();
332 let manifest: serde_json::Value =
333 serde_json::from_str(&result).unwrap();
334 let desc = manifest["description"].as_str().unwrap();
335 assert!(desc.starts_with("This is a very long"));
336 assert!(desc.ends_with("..."));
337 Ok(())
338 }
339
340 #[test]
341 fn test_find_full_description_fallback() {
342 let mut entries = Vec::new();
343 let mut meta1 = std::collections::HashMap::new();
344 let _ = meta1.insert("title".to_string(), "No description".to_string());
345 entries.push(("root".to_string(), meta1));
346
347 let mut meta2 = std::collections::HashMap::new();
348 let _ = meta2
349 .insert("description".to_string(), "Fallback desc".to_string());
350 entries.push(("subpage".to_string(), meta2));
351
352 let desc = find_full_description(&entries);
353 assert_eq!(desc.as_deref(), Some("Fallback desc"));
354 }
355
356 #[test]
357 fn test_find_full_description_none() {
358 let mut entries = Vec::new();
359 let mut meta1 = std::collections::HashMap::new();
360 let _ = meta1.insert("title".to_string(), "No description".to_string());
361 entries.push(("root".to_string(), meta1));
362
363 let desc = find_full_description(&entries);
364 assert!(desc.is_none());
365 }
366
367 #[test]
372 #[serial_test::parallel]
373 fn after_compile_handles_manifest_without_description() {
374 let tmp = tempdir().unwrap();
375 let manifest_path = tmp.path().join("manifest.json");
376 fs::write(&manifest_path, r#"{"name":"X"}"#).unwrap();
377 let ctx = test_ctx(tmp.path());
378 ManifestFixPlugin.after_compile(&ctx).unwrap();
379 let after: serde_json::Value =
380 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap())
381 .unwrap();
382 assert!(
383 after.get("description").is_none(),
384 "no description key must be invented"
385 );
386 }
387
388 #[test]
393 #[serial_test::parallel]
394 fn after_compile_errors_on_invalid_utf8_manifest() {
395 let tmp = tempdir().unwrap();
396 let manifest_path = tmp.path().join("manifest.json");
397 fs::write(&manifest_path, [0xFF, 0xFE, 0xFD]).unwrap();
398 let ctx = test_ctx(tmp.path());
399 let err = ManifestFixPlugin.after_compile(&ctx).unwrap_err();
400 assert!(format!("{err}").contains("manifest.json"));
401 }
402
403 #[test]
404 #[cfg(unix)]
405 fn after_compile_write_failure_on_readonly_manifest() {
406 use std::os::unix::fs::PermissionsExt;
407 let tmp = tempdir().unwrap();
408 let manifest_path = tmp.path().join("manifest.json");
409 fs::write(&manifest_path, r#"{"name":"X","description":"Done."}"#)
410 .unwrap();
411 fs::set_permissions(&manifest_path, fs::Permissions::from_mode(0o444))
412 .unwrap();
413
414 let ctx = test_ctx(tmp.path());
415 let result = ManifestFixPlugin.after_compile(&ctx);
416 let _ = fs::set_permissions(
417 &manifest_path,
418 fs::Permissions::from_mode(0o644),
419 );
420 let err = result.unwrap_err();
421 assert!(format!("{err}").contains("manifest.json"));
422 }
423}
424
425#[cfg(all(test, feature = "test-fault-injection"))]
426mod fault_tests {
427 use super::*;
428 use crate::plugin::PluginContext;
429 use serial_test::serial;
430 use std::path::Path;
431 use tempfile::tempdir;
432
433 struct FailGuard(&'static str);
435
436 impl Drop for FailGuard {
437 fn drop(&mut self) {
438 let _ = fail::cfg(self.0, "off");
439 }
440 }
441
442 #[test]
443 #[serial]
444 fn after_compile_maps_serialize_failure_to_io_error() {
445 let _guard = FailGuard("postprocess::manifest-serialize");
446 fail::cfg("postprocess::manifest-serialize", "return")
447 .expect("activate failpoint");
448
449 let tmp = tempdir().unwrap();
450 fs::write(
451 tmp.path().join("manifest.json"),
452 r#"{"name":"X","description":"Already terminated."}"#,
453 )
454 .unwrap();
455 crate::test_support::init_logger();
456 let ctx = PluginContext::new(
457 Path::new("content"),
458 Path::new("build"),
459 tmp.path(),
460 Path::new("templates"),
461 );
462 let err = ManifestFixPlugin
463 .after_compile(&ctx)
464 .expect_err("injected serialize failure must propagate");
465 let msg = format!("{err}");
466 assert!(msg.contains("manifest.json"), "got: {msg}");
467 assert!(
468 msg.contains("injected: postprocess::manifest-serialize"),
469 "got: {msg}"
470 );
471 }
472}