1use forma_ir::parser::IrModule;
2use rust_embed::Embed;
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::fmt;
6use std::marker::PhantomData;
7use std::sync::Arc;
8
9use crate::{AssetManifest, RouteMode};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct IrLoadError {
26 pub route: String,
28 pub ir_file: String,
30 pub reason: String,
33}
34
35impl IrLoadError {
36 pub fn code(&self) -> Option<&str> {
39 let code = self.reason.split(' ').next()?;
40 let digits = code.strip_prefix("LOAD-")?;
41 (digits.len() == 3 && digits.bytes().all(|b| b.is_ascii_digit())).then_some(code)
42 }
43}
44
45fn parse_failure_code(error: &forma_ir::IrError) -> &'static str {
47 error.code()
48}
49
50impl std::fmt::Display for IrLoadError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 write!(
53 f,
54 "route {}: IR file {}: {}",
55 self.route, self.ir_file, self.reason
56 )
57 }
58}
59
60impl std::error::Error for IrLoadError {}
61
62type Read<'r> = &'r dyn Fn(&str) -> Option<Cow<'static, [u8]>>;
67
68enum Refusal {
70 NotEmbedded,
72 Parse(forma_ir::IrError),
74}
75
76impl Refusal {
77 fn error(&self, route: &str, ir_file: &str) -> IrLoadError {
79 let reason = match self {
80 Refusal::NotEmbedded => {
81 "LOAD-101 declared in the manifest but not embedded".to_string()
82 }
83 Refusal::Parse(e) => format!("{} {e}", parse_failure_code(e)),
84 };
85 IrLoadError {
86 route: route.to_string(),
87 ir_file: ir_file.to_string(),
88 reason,
89 }
90 }
91}
92
93fn check_module(bytes: Option<&[u8]>) -> Result<IrModule, Refusal> {
96 let bytes = bytes.ok_or(Refusal::NotEmbedded)?;
97 IrModule::parse(bytes).map_err(Refusal::Parse)
98}
99
100struct Loaded {
103 route: String,
104 #[cfg_attr(not(debug_assertions), allow(dead_code))]
105 ir_file: String,
106 #[cfg_attr(not(debug_assertions), allow(dead_code))]
107 bytes: Cow<'static, [u8]>,
108 module: IrModule,
109}
110
111fn load_from(
122 manifest: &AssetManifest,
123 read: Read<'_>,
124) -> (HashMap<String, RouteMode>, Vec<Loaded>, Vec<IrLoadError>) {
125 let mut render_modes = HashMap::new();
126 let mut loaded = Vec::new();
127 let mut errors = Vec::new();
128 if let Err(reason) = manifest.validate() {
129 errors.push(IrLoadError {
130 route: "<manifest>".into(),
131 ir_file: "manifest.json".into(),
132 reason,
133 });
134 return (render_modes, loaded, errors);
135 }
136
137 for (route_pattern, route_assets) in &manifest.routes {
138 if route_assets.mode == crate::RouteMode::Browser {
139 render_modes.insert(route_pattern.clone(), RouteMode::Browser);
140 continue;
141 }
142 let Some(ref ir_filename) = route_assets.ir else {
143 continue;
144 };
145
146 let bytes = read(ir_filename);
147 match check_module(bytes.as_deref()) {
148 Ok(module) => {
149 tracing::info!(
150 route = %route_pattern,
151 ir_file = %ir_filename,
152 "Loaded IR module for SSR"
153 );
154 render_modes.insert(route_pattern.clone(), RouteMode::Ssr);
155 loaded.push(Loaded {
156 route: route_pattern.clone(),
157 ir_file: ir_filename.clone(),
158 bytes: bytes.unwrap_or_default(),
159 module,
160 });
161 }
162 Err(refusal) => {
163 errors.push(refusal.error(route_pattern, ir_filename));
164 }
165 }
166 }
167
168 if !loaded.is_empty() {
169 tracing::info!(
170 ssr_routes = loaded.len(),
171 "Phase 2 SSR enabled for {} route(s)",
172 loaded.len()
173 );
174 }
175
176 (render_modes, loaded, errors)
177}
178
179fn embedded<A: Embed>(name: &str) -> Option<Cow<'static, [u8]>> {
181 A::get(name).map(|file| file.data)
182}
183
184pub fn load_ir_modules<A: Embed + 'static>(
188 manifest: &AssetManifest,
189) -> Result<crate::Pages, Vec<IrLoadError>> {
190 crate::Pages::load::<A>(manifest)
191}
192
193pub(crate) struct IrModules<A> {
195 modules: Modules,
196 assets: PhantomData<fn() -> A>,
197}
198
199impl<A: Embed> IrModules<A> {
200 pub(crate) fn load(manifest: &AssetManifest) -> Result<Self, Vec<IrLoadError>> {
209 Ok(IrModules {
210 modules: Modules::load(manifest, &embedded::<A>)?,
211 assets: PhantomData,
212 })
213 }
214
215 pub(crate) fn freeze_topology(&self) -> Result<(), String> {
216 #[cfg(debug_assertions)]
217 {
218 self.modules
219 .dev
220 .lock()
221 .unwrap_or_else(|p| p.into_inner())
222 .freeze_topology()
223 }
224 #[cfg(not(debug_assertions))]
225 {
226 Ok(())
227 }
228 }
229
230 pub(crate) fn snapshot(&self, route: &str) -> Option<(Arc<IrModule>, Arc<AssetManifest>)> {
233 self.modules.snapshot(route, &embedded::<A>)
234 }
235}
236
237impl<A> fmt::Debug for IrModules<A> {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.debug_struct("IrModules")
240 .field("routes", &self.modules.routes())
241 .finish()
242 }
243}
244
245struct Modules {
248 #[cfg(not(debug_assertions))]
249 manifest: Arc<AssetManifest>,
250 #[cfg(not(debug_assertions))]
251 by_route: HashMap<String, Arc<IrModule>>,
252 #[cfg(debug_assertions)]
253 dev: std::sync::Mutex<dev::Pages>,
254}
255
256impl Modules {
257 fn load(manifest: &AssetManifest, read: Read<'_>) -> Result<Self, Vec<IrLoadError>> {
258 let (_, loaded, errors) = load_from(manifest, read);
259 if !errors.is_empty() {
260 return Err(errors);
261 }
262 #[cfg(not(debug_assertions))]
263 let modules = Modules {
264 manifest: Arc::new(manifest.clone()),
265 by_route: loaded
266 .into_iter()
267 .map(|l| (l.route, Arc::new(l.module)))
268 .collect(),
269 };
270 #[cfg(debug_assertions)]
271 let modules = Modules {
272 dev: std::sync::Mutex::new(dev::Pages::new(manifest, loaded, read)),
273 };
274 Ok(modules)
275 }
276
277 #[cfg(not(debug_assertions))]
279 fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
280 let _ = read;
281 self.by_route.get(route).cloned()
282 }
283
284 #[cfg(all(debug_assertions, test))]
286 fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
287 let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
290 pages.get(route, read)
291 }
292
293 fn snapshot(&self, route: &str, read: Read<'_>) -> Option<(Arc<IrModule>, Arc<AssetManifest>)> {
294 #[cfg(not(debug_assertions))]
295 {
296 self.get(route, read)
297 .map(|module| (module, Arc::clone(&self.manifest)))
298 }
299 #[cfg(debug_assertions)]
300 {
301 let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
302 let module = pages.get(route, read)?;
303 Some((module, pages.manifest_for(route)?))
304 }
305 }
306
307 fn routes(&self) -> Vec<String> {
308 #[cfg(not(debug_assertions))]
309 let mut routes: Vec<String> = self.by_route.keys().cloned().collect();
310 #[cfg(debug_assertions)]
311 let mut routes = self.dev.lock().unwrap_or_else(|p| p.into_inner()).routes();
312 routes.sort();
313 routes
314 }
315}
316
317#[cfg(debug_assertions)]
319mod dev {
320 use super::{check_module, Loaded, Read};
321 use crate::{AssetManifest, RouteMode};
322 use forma_ir::parser::IrModule;
323 use std::borrow::Cow;
324 use std::collections::HashMap;
325 use std::sync::Arc;
326
327 pub(super) struct Pages {
329 manifest: Option<Vec<u8>>,
331 manifest_rejected: bool,
334 accepted_manifest: Arc<AssetManifest>,
335 initial_topology: HashMap<String, RouteMode>,
336 frozen_topology: bool,
337 names: HashMap<String, String>,
339 pages: HashMap<String, Page>,
341 }
342
343 #[derive(Default)]
344 struct Page {
345 manifest: Option<Arc<AssetManifest>>,
346 current: Option<(String, Cow<'static, [u8]>, Arc<IrModule>)>,
348 refused: Option<(String, Option<Vec<u8>>)>,
350 }
351
352 fn topology(manifest: &AssetManifest) -> HashMap<String, RouteMode> {
353 manifest
354 .routes
355 .iter()
356 .map(|(name, route)| (name.clone(), route.mode))
357 .collect()
358 }
359
360 impl Pages {
361 pub(super) fn new(manifest: &AssetManifest, loaded: Vec<Loaded>, read: Read<'_>) -> Self {
362 let names = manifest
363 .routes
364 .iter()
365 .filter_map(|(route, assets)| Some((route.clone(), assets.ir.clone()?)))
366 .collect();
367 let pages = loaded
368 .into_iter()
369 .map(|l| {
370 let page = Page {
371 manifest: Some(Arc::new(manifest.clone())),
372 current: Some((l.ir_file, l.bytes, Arc::new(l.module))),
373 refused: None,
374 };
375 (l.route, page)
376 })
377 .collect();
378 let baseline = read("manifest.json").map(Cow::into_owned);
382 let manifest_rejected = match baseline.as_deref() {
383 Some(bytes) => match serde_json::from_slice::<AssetManifest>(bytes) {
384 Ok(_) => false,
385 Err(error) => {
386 tracing::warn!("manifest.json does not parse ({error}); the previous module and assets stay together (development reload)");
387 true
388 }
389 },
390 None => {
391 tracing::warn!("manifest.json is missing; the previous module and assets stay together (development reload)");
392 true
393 }
394 };
395 Pages {
396 accepted_manifest: Arc::new(manifest.clone()),
397 initial_topology: topology(manifest),
398 frozen_topology: false,
399 manifest: baseline,
400 manifest_rejected,
401 names,
402 pages,
403 }
404 }
405
406 pub(super) fn freeze_topology(&mut self) -> Result<(), String> {
407 if topology(&self.accepted_manifest) != self.initial_topology {
408 return Err("route names or modes changed during startup; reload the registry and bind again".into());
409 }
410 self.frozen_topology = true;
411 Ok(())
412 }
413
414 pub(super) fn routes(&self) -> Vec<String> {
415 self.pages
416 .iter()
417 .filter(|(_, p)| p.current.is_some())
418 .map(|(r, _)| r.clone())
419 .collect()
420 }
421
422 pub(super) fn get(&mut self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
423 if !self.reread_manifest(read) {
424 return self.current(route);
425 }
426 let Some(file) = self.names.get(route).cloned() else {
427 return self.current(route);
428 };
429 let bytes = read(&file);
430 let page = self.pages.entry(route.to_string()).or_default();
431 let unchanged = page
432 .current
433 .as_ref()
434 .is_some_and(|(loaded_from, loaded, _)| {
435 *loaded_from == file && Some(loaded) == bytes.as_ref()
436 });
437 if unchanged {
438 page.refused = None;
441 page.manifest = Some(Arc::clone(&self.accepted_manifest));
442 return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
443 }
444 let attempt = (file.clone(), bytes.as_deref().map(<[u8]>::to_vec));
445 if page.refused.as_ref() == Some(&attempt) {
446 return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
448 }
449 match check_module(bytes.as_deref()) {
450 Ok(module) => {
451 tracing::info!(
452 route,
453 ir_file = %file,
454 "Reloaded the IR module the build rewrote (development)"
455 );
456 page.current = Some((file, bytes.unwrap_or_default(), Arc::new(module)));
457 page.manifest = Some(Arc::clone(&self.accepted_manifest));
458 page.refused = None;
459 }
460 Err(refusal) => {
461 let error = refusal.error(route, &file);
462 tracing::warn!(
463 code = error.code().unwrap_or_default(),
464 route,
465 ir_file = %file,
466 "{error}; the previous module stays until the build writes one that \
467 loads (development reload)"
468 );
469 page.refused = Some(attempt);
470 }
471 }
472 page.current.as_ref().map(|(_, _, m)| Arc::clone(m))
473 }
474
475 pub(super) fn manifest_for(&self, route: &str) -> Option<Arc<AssetManifest>> {
476 self.pages.get(route)?.manifest.clone()
477 }
478
479 fn current(&self, route: &str) -> Option<Arc<IrModule>> {
480 self.pages
481 .get(route)
482 .and_then(|p| p.current.as_ref())
483 .map(|(_, _, m)| Arc::clone(m))
484 }
485
486 fn reread_manifest(&mut self, read: Read<'_>) -> bool {
492 let Some(bytes) = read("manifest.json") else {
493 if self.manifest.is_some() || !self.manifest_rejected {
494 tracing::warn!("manifest.json is missing; the previous module and assets stay together (development reload)");
495 }
496 self.manifest = None;
497 self.manifest_rejected = true;
498 return false;
499 };
500 if self.manifest.as_deref() == Some(&bytes[..]) {
501 return !self.manifest_rejected;
502 }
503 self.manifest_rejected = true;
504 match serde_json::from_slice::<AssetManifest>(&bytes) {
505 Ok(manifest)
506 if self.frozen_topology && topology(&manifest) != self.initial_topology =>
507 {
508 tracing::warn!(code = "BIND-112", "route names or modes changed after finalization; restart and bind the changed routes; previous module and manifest stay (development reload)");
509 }
510 Ok(manifest) => {
511 self.manifest_rejected = false;
512 self.accepted_manifest = Arc::new(manifest.clone());
513 self.names = manifest
514 .routes
515 .into_iter()
516 .filter_map(|(route, assets)| assets.ir.map(|file| (route, file)))
517 .collect();
518 self.pages.retain(|route, _| self.names.contains_key(route));
521 }
522 Err(e) => tracing::warn!(
523 "manifest.json does not parse ({e}); the previous module and assets stay together \
524 (development reload)"
525 ),
526 }
527 self.manifest = Some(bytes.into_owned());
528 !self.manifest_rejected
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::types::RouteAssets;
537 use rust_embed::Embed;
538
539 #[derive(Embed)]
540 #[folder = "test_fixtures/"]
541 struct Fixtures;
542
543 fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
544 let mut routes = HashMap::new();
545 routes.insert(
546 route.to_string(),
547 RouteAssets {
548 mode: if ir.is_some() {
549 crate::RouteMode::Ssr
550 } else {
551 crate::RouteMode::Browser
552 },
553 modulepreload: Vec::new(),
554 js: vec![],
555 css: vec![],
556 fonts: vec![],
557 total_size_br: 0,
558 budget_warn_threshold: 0,
559 ir: ir.map(str::to_string),
560 },
561 );
562 AssetManifest {
563 version: 2,
564 writer: "3.0.0".into(),
565 app: "ledger".into(),
566 aliases: HashMap::new(),
567 build: "test".to_string(),
568 assets: ir
569 .into_iter()
570 .map(|f| {
571 (
572 f.to_string(),
573 crate::AssetMetadata {
574 immutable: false,
575 integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb".into(),
576 retained: false,
577 },
578 )
579 })
580 .collect(),
581 routes,
582 }
583 }
584
585 #[test]
586 fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
587 let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
590 let errors = load_ir_modules::<Fixtures>(&manifest).unwrap_err();
591 assert_eq!(errors.len(), 1);
592 assert_eq!(errors[0].route, "/");
593 assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
594 assert_eq!(errors[0].code(), Some("LOAD-101"));
595 }
596
597 #[test]
598 fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
599 let error = |reason: &str| IrLoadError {
600 route: "/".to_string(),
601 ir_file: "page.ir".to_string(),
602 reason: reason.to_string(),
603 };
604 assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
605 assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
606 for reason in [
608 "bad magic",
609 "",
610 "LOAD-1 x",
611 "LOAD-1023 x",
612 "LOAD-10x y",
613 "load-102 x",
614 ] {
615 assert_eq!(error(reason).code(), None, "{reason:?}");
616 }
617 assert_eq!(
619 parse_failure_code(&forma_ir::IrError::BadSignature(*b"INVALID!")),
620 "LOAD-102"
621 );
622 assert_eq!(
623 parse_failure_code(&forma_ir::IrError::LegacyModule { version: 2 }),
624 "LOAD-102"
625 );
626 assert_eq!(
627 parse_failure_code(&forma_ir::IrError::ReservedBits { field: "header" }),
628 "LOAD-103"
629 );
630 assert_eq!(
631 parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
632 "LOAD-103"
633 );
634 }
635
636 #[test]
637 fn a_route_with_no_ir_field_is_not_an_error() {
638 let manifest = manifest_with_ir("/", None);
640 let pages = load_ir_modules::<Fixtures>(&manifest).expect("must not error");
641 pages.finalize().unwrap();
642 }
643}
644
645#[cfg(test)]
648mod reload_tests {
649 use super::*;
650 use std::sync::atomic::{AtomicUsize, Ordering};
651 use std::sync::Mutex;
652
653 #[derive(Default)]
655 struct Disk {
656 files: Mutex<HashMap<String, Vec<u8>>>,
657 reads: AtomicUsize,
658 }
659
660 impl Disk {
661 fn write(&self, name: &str, bytes: Vec<u8>) {
662 self.files
663 .lock()
664 .expect("lock")
665 .insert(name.to_string(), bytes);
666 }
667 fn remove(&self, name: &str) {
668 self.files.lock().expect("lock").remove(name);
669 }
670 fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
671 self.reads.fetch_add(1, Ordering::SeqCst);
672 self.files
673 .lock()
674 .expect("lock")
675 .get(name)
676 .cloned()
677 .map(Cow::Owned)
678 }
679 fn reads(&self) -> usize {
680 self.reads.load(Ordering::SeqCst)
681 }
682 }
683
684 fn page(text: &str) -> Vec<u8> {
686 use forma_ir::parser::test_helpers::{
687 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
688 };
689 let mut ops = encode_open_tag(0, &[]);
690 ops.extend_from_slice(&encode_text(1));
691 ops.extend_from_slice(&encode_close_tag(0));
692 build_minimal_ir(&["p", text], &[], &ops, &[])
693 }
694
695 fn html(module: Option<Arc<IrModule>>) -> String {
696 let module = module.expect("a module");
697 forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
698 }
699
700 fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
702 let routes: serde_json::Map<String, serde_json::Value> = routes
703 .iter()
704 .map(|(route, ir)| {
705 let mut assets = serde_json::json!({
706 "mode": if ir.is_some(){"ssr"}else{"browser"},
707 "js": ["app.js"], "css": [], "fonts": [],
708 "total_size_br": 0, "budget_warn_threshold": 0
709 });
710 if let Some(ir) = ir {
711 assets["ir"] = (*ir).into();
712 }
713 (route.to_string(), assets)
714 })
715 .collect();
716 let mut assets = serde_json::Map::new();
717 assets.insert("app.js".into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
718 for route in routes.values() {
719 if let Some(ir) = route.get("ir").and_then(|v| v.as_str()) {
720 assets.insert(ir.into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
721 }
722 }
723 serde_json::json!({"version": 2, "writer":"3.0.0", "app":"ledger", "build": "dev", "aliases":{}, "assets": assets, "routes": routes})
724 .to_string()
725 .into_bytes()
726 }
727
728 fn started(first: &str) -> (Disk, AssetManifest) {
731 let disk = Disk::default();
732 let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
733 disk.write("manifest.json", json.clone());
734 disk.write("page.ir", page(first));
735 let manifest = serde_json::from_slice(&json).expect("manifest");
736 (disk, manifest)
737 }
738
739 #[test]
740 fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
741 let (disk, manifest) = started("first");
742 let read = |name: &str| disk.read(name);
743 let modules = Modules::load(&manifest, &read).expect("loads");
744 let after_load = disk.reads();
745 assert!(after_load >= 1, "the start reads the module");
746
747 for _ in 0..3 {
748 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
749 }
750 assert!(
751 modules.get("/app", &read).is_none(),
752 "a route with no module"
753 );
754 disk.write("page.ir", page("second"));
756 let served = html(modules.get("/", &read));
757
758 if cfg!(debug_assertions) {
759 assert_eq!(
760 served, "<p>second</p>",
761 "debug: the next get serves the rewrite"
762 );
763 assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
765 } else {
766 assert_eq!(
767 served, "<p>first</p>",
768 "release: the module loaded at start"
769 );
770 assert_eq!(disk.reads(), after_load, "release: no read after start");
771 }
772 }
773
774 #[test]
775 fn the_reload_loads_through_the_strict_loader_at_start() {
776 let (disk, manifest) = started("first");
779 let mut flipped = page("first");
780 flipped[0] ^= 0xFF;
781 disk.write("page.ir", flipped);
782 let read = |name: &str| disk.read(name);
783 let errors = Modules::load(&manifest, &read).err().expect("refused");
784 assert_eq!(errors.len(), 1);
785 assert_eq!(errors[0].route, "/");
786 assert_eq!(errors[0].ir_file, "page.ir");
787 assert_eq!(errors[0].code(), Some("LOAD-102"));
788 disk.remove("page.ir");
789 let errors = Modules::load(&manifest, &read).err().expect("refused");
790 assert_eq!(errors[0].code(), Some("LOAD-101"));
791 }
792
793 #[cfg(debug_assertions)]
794 mod development {
795 use super::*;
796 use tracing::field::{Field, Visit};
797 use tracing::span::{Attributes, Id, Record};
798 use tracing::{Event, Metadata, Subscriber};
799
800 #[derive(Clone, Default)]
802 struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
803
804 impl Subscriber for Warnings {
805 fn enabled(&self, _: &Metadata<'_>) -> bool {
806 true
807 }
808 fn new_span(&self, _: &Attributes<'_>) -> Id {
809 Id::from_u64(1)
810 }
811 fn record(&self, _: &Id, _: &Record<'_>) {}
812 fn record_follows_from(&self, _: &Id, _: &Id) {}
813 fn event(&self, event: &Event<'_>) {
814 #[derive(Default)]
815 struct Fields(String, String);
816 impl Visit for Fields {
817 fn record_str(&mut self, field: &Field, value: &str) {
818 if field.name() == "code" {
819 self.0 = value.to_string();
820 }
821 }
822 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
823 if field.name() == "message" {
824 self.1 = format!("{value:?}");
825 }
826 }
827 }
828 if *event.metadata().level() == tracing::Level::WARN {
829 let mut fields = Fields::default();
830 event.record(&mut fields);
831 self.0.lock().expect("lock").push((fields.0, fields.1));
832 }
833 }
834 fn enter(&self, _: &Id) {}
835 fn exit(&self, _: &Id) {}
836 }
837
838 impl Warnings {
839 fn codes(&self) -> Vec<String> {
840 self.0
841 .lock()
842 .expect("lock")
843 .iter()
844 .map(|(c, _)| c.clone())
845 .collect()
846 }
847 }
848
849 #[test]
850 fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
851 let (disk, manifest) = started("first");
852 let read = |name: &str| disk.read(name);
853 let modules = Modules::load(&manifest, &read).expect("loads");
854 let warnings = Warnings::default();
855 tracing::subscriber::with_default(warnings.clone(), || {
856 let good = page("second");
859 disk.write("page.ir", good[..good.len() - 3].to_vec());
860 for _ in 0..3 {
861 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
862 }
863 assert_eq!(warnings.codes(), ["LOAD-104"]);
864 let (_, message) = warnings.0.lock().expect("lock")[0].clone();
865 assert!(
866 message.contains("route /: IR file page.ir: LOAD-104"),
867 "{message}"
868 );
869 assert!(message.contains("the previous module stays"), "{message}");
870
871 let mut magic = page("second");
873 magic[0] ^= 0xFF;
874 disk.write("page.ir", magic.clone());
875 for _ in 0..3 {
876 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
877 }
878 disk.remove("page.ir");
880 for _ in 0..3 {
881 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
882 }
883 assert_eq!(warnings.codes(), ["LOAD-104", "LOAD-102", "LOAD-101"]);
884
885 disk.write("page.ir", page("second"));
887 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
888 disk.write("page.ir", magic.clone());
890 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
891 assert_eq!(
892 warnings.codes(),
893 ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102"]
894 );
895 disk.write("page.ir", page("second"));
898 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
899 disk.write("page.ir", magic);
900 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
901 assert_eq!(
902 warnings.codes(),
903 ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
904 );
905 });
906 }
907
908 #[test]
909 fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
910 let (disk, manifest) = started("first");
911 let read = |name: &str| disk.read(name);
912 let modules = Modules::load(&manifest, &read).expect("loads");
913 assert!(modules.get("/late", &read).is_none());
914 let warnings = Warnings::default();
915 tracing::subscriber::with_default(warnings.clone(), || {
916 disk.write("page.2.ir", page("renamed"));
919 disk.write("late.ir", page("late"));
920 disk.write(
921 "manifest.json",
922 manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
923 );
924 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
925 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
926 assert!(warnings.codes().is_empty());
927
928 disk.write("manifest.json", b"{\"routes\":".to_vec());
930 for _ in 0..3 {
931 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
932 }
933 let logged = warnings.0.lock().expect("lock").clone();
934 assert_eq!(logged.len(), 1, "{logged:?}");
935 assert!(
936 logged[0].1.contains("manifest.json does not parse"),
937 "{logged:?}"
938 );
939
940 let mut incompatible: serde_json::Value =
943 serde_json::from_slice(&manifest_json(&[("/", Some("page.2.ir"))])).unwrap();
944 incompatible["writer"] = "3.1.0".into();
945 disk.write("manifest.json", incompatible.to_string().into_bytes());
946 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
947 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
948 assert!(warnings
949 .0
950 .lock()
951 .unwrap()
952 .last()
953 .unwrap()
954 .1
955 .contains("MANIFEST-102"));
956
957 disk.write("manifest.json", manifest_json(&[("/", None)]));
960 assert!(modules.get("/", &read).is_none());
961 assert!(modules.get("/late", &read).is_none());
962 });
963 }
964 }
965}