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