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 mode: if ir.is_some() {
578 crate::RouteMode::Ssr
579 } else {
580 crate::RouteMode::Browser
581 },
582 modulepreload: Vec::new(),
583 js: vec![],
584 css: vec![],
585 fonts: vec![],
586 total_size_br: 0,
587 budget_warn_threshold: 0,
588 ir: ir.map(str::to_string),
589 },
590 );
591 AssetManifest {
592 version: 2,
593 writer: "3.0.0".into(),
594 app: "ledger".into(),
595 aliases: HashMap::new(),
596 build: "test".to_string(),
597 assets: ir
598 .into_iter()
599 .map(|f| {
600 (
601 f.to_string(),
602 crate::AssetMetadata {
603 immutable: false,
604 integrity: "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb".into(),
605 retained: false,
606 },
607 )
608 })
609 .collect(),
610 routes,
611 }
612 }
613
614 #[test]
615 fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
616 let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
619 let errors = load_ir_modules::<Fixtures>(&manifest).unwrap_err();
620 assert_eq!(errors.len(), 1);
621 assert_eq!(errors[0].route, "/");
622 assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
623 assert_eq!(errors[0].code(), Some("LOAD-101"));
624 }
625
626 #[test]
627 fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
628 let error = |reason: &str| IrLoadError {
629 route: "/".to_string(),
630 ir_file: "page.ir".to_string(),
631 reason: reason.to_string(),
632 };
633 assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
634 assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
635 for reason in [
637 "bad magic",
638 "",
639 "LOAD-1 x",
640 "LOAD-1023 x",
641 "LOAD-10x y",
642 "load-102 x",
643 ] {
644 assert_eq!(error(reason).code(), None, "{reason:?}");
645 }
646 assert_eq!(
648 parse_failure_code(&forma_ir::IrError::BadSignature(*b"INVALID!")),
649 "LOAD-102"
650 );
651 assert_eq!(
652 parse_failure_code(&forma_ir::IrError::LegacyModule { version: 2 }),
653 "LOAD-102"
654 );
655 assert_eq!(
656 parse_failure_code(&forma_ir::IrError::ReservedBits { field: "header" }),
657 "LOAD-103"
658 );
659 assert_eq!(
660 parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
661 "LOAD-103"
662 );
663 }
664
665 #[test]
666 fn a_route_with_no_ir_field_is_not_an_error() {
667 let manifest = manifest_with_ir("/", None);
669 let pages = load_ir_modules::<Fixtures>(&manifest).expect("must not error");
670 pages.finalize().unwrap();
671 }
672}
673
674#[cfg(test)]
677mod reload_tests {
678 use super::*;
679 use std::sync::atomic::{AtomicUsize, Ordering};
680 use std::sync::Mutex;
681
682 #[derive(Default)]
684 struct Disk {
685 files: Mutex<HashMap<String, Vec<u8>>>,
686 reads: AtomicUsize,
687 }
688
689 impl Disk {
690 fn write(&self, name: &str, bytes: Vec<u8>) {
691 self.files
692 .lock()
693 .expect("lock")
694 .insert(name.to_string(), bytes);
695 }
696 fn remove(&self, name: &str) {
697 self.files.lock().expect("lock").remove(name);
698 }
699 fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
700 self.reads.fetch_add(1, Ordering::SeqCst);
701 self.files
702 .lock()
703 .expect("lock")
704 .get(name)
705 .cloned()
706 .map(Cow::Owned)
707 }
708 fn reads(&self) -> usize {
709 self.reads.load(Ordering::SeqCst)
710 }
711 }
712
713 fn page(text: &str) -> Vec<u8> {
715 use forma_ir::parser::test_helpers::{
716 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
717 };
718 let mut ops = encode_open_tag(0, &[]);
719 ops.extend_from_slice(&encode_text(1));
720 ops.extend_from_slice(&encode_close_tag(0));
721 build_minimal_ir(&["p", text], &[], &ops, &[])
722 }
723
724 fn html(module: Option<Arc<IrModule>>) -> String {
725 let module = module.expect("a module");
726 forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
727 }
728
729 fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
731 let routes: serde_json::Map<String, serde_json::Value> = routes
732 .iter()
733 .map(|(route, ir)| {
734 let mut assets = serde_json::json!({
735 "mode": if ir.is_some(){"ssr"}else{"browser"},
736 "js": ["app.js"], "css": [], "fonts": [],
737 "total_size_br": 0, "budget_warn_threshold": 0
738 });
739 if let Some(ir) = ir {
740 assets["ir"] = (*ir).into();
741 }
742 (route.to_string(), assets)
743 })
744 .collect();
745 let mut assets = serde_json::Map::new();
746 assets.insert("app.js".into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
747 for route in routes.values() {
748 if let Some(ir) = route.get("ir").and_then(|v| v.as_str()) {
749 assets.insert(ir.into(), serde_json::json!({"immutable":false,"integrity":"sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"}));
750 }
751 }
752 serde_json::json!({"version": 2, "writer":"3.0.0", "app":"ledger", "build": "dev", "aliases":{}, "assets": assets, "routes": routes})
753 .to_string()
754 .into_bytes()
755 }
756
757 fn started(first: &str) -> (Disk, AssetManifest) {
760 let disk = Disk::default();
761 let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
762 disk.write("manifest.json", json.clone());
763 disk.write("page.ir", page(first));
764 let manifest = serde_json::from_slice(&json).expect("manifest");
765 (disk, manifest)
766 }
767
768 #[test]
769 fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
770 let (disk, manifest) = started("first");
771 let read = |name: &str| disk.read(name);
772 let modules = Modules::load(&manifest, &read).expect("loads");
773 let after_load = disk.reads();
774 assert!(after_load >= 1, "the start reads the module");
775
776 for _ in 0..3 {
777 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
778 }
779 assert!(
780 modules.get("/app", &read).is_none(),
781 "a route with no module"
782 );
783 disk.write("page.ir", page("second"));
785 let served = html(modules.get("/", &read));
786
787 if cfg!(debug_assertions) {
788 assert_eq!(
789 served, "<p>second</p>",
790 "debug: the next get serves the rewrite"
791 );
792 assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
794 } else {
795 assert_eq!(
796 served, "<p>first</p>",
797 "release: the module loaded at start"
798 );
799 assert_eq!(disk.reads(), after_load, "release: no read after start");
800 }
801 }
802
803 #[test]
804 fn the_reload_loads_through_the_strict_loader_at_start() {
805 let (disk, manifest) = started("first");
808 let mut flipped = page("first");
809 flipped[0] ^= 0xFF;
810 disk.write("page.ir", flipped);
811 let read = |name: &str| disk.read(name);
812 let errors = Modules::load(&manifest, &read).err().expect("refused");
813 assert_eq!(errors.len(), 1);
814 assert_eq!(errors[0].route, "/");
815 assert_eq!(errors[0].ir_file, "page.ir");
816 assert_eq!(errors[0].code(), Some("LOAD-102"));
817 disk.remove("page.ir");
818 let errors = Modules::load(&manifest, &read).err().expect("refused");
819 assert_eq!(errors[0].code(), Some("LOAD-101"));
820 }
821
822 #[cfg(debug_assertions)]
823 mod development {
824 use super::*;
825 use tracing::field::{Field, Visit};
826 use tracing::span::{Attributes, Id, Record};
827 use tracing::{Event, Metadata, Subscriber};
828
829 #[derive(Clone, Default)]
831 struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
832
833 impl Subscriber for Warnings {
834 fn enabled(&self, _: &Metadata<'_>) -> bool {
835 true
836 }
837 fn new_span(&self, _: &Attributes<'_>) -> Id {
838 Id::from_u64(1)
839 }
840 fn record(&self, _: &Id, _: &Record<'_>) {}
841 fn record_follows_from(&self, _: &Id, _: &Id) {}
842 fn event(&self, event: &Event<'_>) {
843 #[derive(Default)]
844 struct Fields(String, String);
845 impl Visit for Fields {
846 fn record_str(&mut self, field: &Field, value: &str) {
847 if field.name() == "code" {
848 self.0 = value.to_string();
849 }
850 }
851 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
852 if field.name() == "message" {
853 self.1 = format!("{value:?}");
854 }
855 }
856 }
857 if *event.metadata().level() == tracing::Level::WARN {
858 let mut fields = Fields::default();
859 event.record(&mut fields);
860 self.0.lock().expect("lock").push((fields.0, fields.1));
861 }
862 }
863 fn enter(&self, _: &Id) {}
864 fn exit(&self, _: &Id) {}
865 }
866
867 impl Warnings {
868 fn codes(&self) -> Vec<String> {
869 self.0
870 .lock()
871 .expect("lock")
872 .iter()
873 .map(|(c, _)| c.clone())
874 .collect()
875 }
876 }
877
878 #[test]
879 fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
880 let (disk, manifest) = started("first");
881 let read = |name: &str| disk.read(name);
882 let modules = Modules::load(&manifest, &read).expect("loads");
883 let warnings = Warnings::default();
884 tracing::subscriber::with_default(warnings.clone(), || {
885 let good = page("second");
888 disk.write("page.ir", good[..good.len() - 3].to_vec());
889 for _ in 0..3 {
890 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
891 }
892 assert_eq!(warnings.codes(), ["LOAD-104"]);
893 let (_, message) = warnings.0.lock().expect("lock")[0].clone();
894 assert!(
895 message.contains("route /: IR file page.ir: LOAD-104"),
896 "{message}"
897 );
898 assert!(message.contains("the previous module stays"), "{message}");
899
900 let mut magic = page("second");
902 magic[0] ^= 0xFF;
903 disk.write("page.ir", magic.clone());
904 for _ in 0..3 {
905 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
906 }
907 disk.remove("page.ir");
909 for _ in 0..3 {
910 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
911 }
912 assert_eq!(warnings.codes(), ["LOAD-104", "LOAD-102", "LOAD-101"]);
913
914 disk.write("page.ir", page("second"));
916 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
917 disk.write("page.ir", magic.clone());
919 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
920 assert_eq!(
921 warnings.codes(),
922 ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102"]
923 );
924 disk.write("page.ir", page("second"));
927 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
928 disk.write("page.ir", magic);
929 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
930 assert_eq!(
931 warnings.codes(),
932 ["LOAD-104", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
933 );
934 });
935 }
936
937 #[test]
938 fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
939 let (disk, manifest) = started("first");
940 let read = |name: &str| disk.read(name);
941 let modules = Modules::load(&manifest, &read).expect("loads");
942 assert!(modules.get("/late", &read).is_none());
943 let warnings = Warnings::default();
944 tracing::subscriber::with_default(warnings.clone(), || {
945 disk.write("page.2.ir", page("renamed"));
948 disk.write("late.ir", page("late"));
949 disk.write(
950 "manifest.json",
951 manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
952 );
953 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
954 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
955 assert!(warnings.codes().is_empty());
956
957 disk.write("manifest.json", b"{\"routes\":".to_vec());
959 for _ in 0..3 {
960 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
961 }
962 let logged = warnings.0.lock().expect("lock").clone();
963 assert_eq!(logged.len(), 1, "{logged:?}");
964 assert!(
965 logged[0].1.contains("manifest.json does not parse"),
966 "{logged:?}"
967 );
968
969 let [major, minor, _] = forma_ir::format::reader_version();
973 let mut incompatible: serde_json::Value =
974 serde_json::from_slice(&manifest_json(&[("/", Some("page.2.ir"))])).unwrap();
975 incompatible["writer"] = format!("{major}.{}.0", minor + 1).into();
976 disk.write("manifest.json", incompatible.to_string().into_bytes());
977 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
978 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
979 assert!(warnings
980 .0
981 .lock()
982 .unwrap()
983 .last()
984 .unwrap()
985 .1
986 .contains("MANIFEST-102"));
987
988 disk.write("manifest.json", manifest_json(&[("/", None)]));
991 assert!(modules.get("/", &read).is_none());
992 assert!(modules.get("/late", &read).is_none());
993 });
994 }
995 }
996}