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, RenderMode};
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 use forma_ir::IrError;
48 match error {
49 IrError::BadMagic(_) | IrError::UnsupportedVersion(_) | IrError::UnsupportedFlags(_) => {
50 "LOAD-102"
51 }
52 _ => "LOAD-103",
53 }
54}
55
56impl std::fmt::Display for IrLoadError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 write!(
59 f,
60 "route {}: IR file {}: {}",
61 self.route, self.ir_file, self.reason
62 )
63 }
64}
65
66impl std::error::Error for IrLoadError {}
67
68type Read<'r> = &'r dyn Fn(&str) -> Option<Cow<'static, [u8]>>;
73
74enum Refusal {
76 NotEmbedded,
78 Parse(forma_ir::IrError),
80}
81
82impl Refusal {
83 fn error(&self, route: &str, ir_file: &str) -> IrLoadError {
85 let reason = match self {
86 Refusal::NotEmbedded => {
87 "LOAD-101 declared in the manifest but not embedded".to_string()
88 }
89 Refusal::Parse(e) => format!("{} {e}", parse_failure_code(e)),
90 };
91 IrLoadError {
92 route: route.to_string(),
93 ir_file: ir_file.to_string(),
94 reason,
95 }
96 }
97}
98
99fn check_module(bytes: Option<&[u8]>) -> Result<IrModule, Refusal> {
102 let bytes = bytes.ok_or(Refusal::NotEmbedded)?;
103 IrModule::parse(bytes).map_err(Refusal::Parse)
104}
105
106struct Loaded {
109 route: String,
110 #[cfg_attr(not(debug_assertions), allow(dead_code))]
111 ir_file: String,
112 #[cfg_attr(not(debug_assertions), allow(dead_code))]
113 bytes: Cow<'static, [u8]>,
114 module: IrModule,
115}
116
117fn load_from(
131 manifest: &AssetManifest,
132 read: Read<'_>,
133 log_fallbacks: bool,
134) -> (HashMap<String, RenderMode>, Vec<Loaded>, Vec<IrLoadError>) {
135 let mut render_modes = HashMap::new();
136 let mut loaded = Vec::new();
137 let mut errors = Vec::new();
138
139 for (route_pattern, route_assets) in &manifest.routes {
140 let Some(ref ir_filename) = route_assets.ir else {
141 continue;
143 };
144
145 let bytes = read(ir_filename);
146 match check_module(bytes.as_deref()) {
147 Ok(module) => {
148 tracing::info!(
149 route = %route_pattern,
150 ir_file = %ir_filename,
151 "Loaded IR module for SSR"
152 );
153 render_modes.insert(route_pattern.clone(), RenderMode::Phase2SsrReconcile);
154 loaded.push(Loaded {
155 route: route_pattern.clone(),
156 ir_file: ir_filename.clone(),
157 bytes: bytes.unwrap_or_default(),
158 module,
159 });
160 }
161 Err(refusal) => {
162 if log_fallbacks {
163 match &refusal {
164 Refusal::NotEmbedded => tracing::warn!(
165 route = %route_pattern,
166 ir_file = %ir_filename,
167 "Manifest declares an IR file that is not embedded — Phase 1 fallback"
168 ),
169 Refusal::Parse(e) => tracing::warn!(
170 route = %route_pattern,
171 ir_file = %ir_filename,
172 error = %e,
173 "Failed to parse IR module — Phase 1 fallback"
174 ),
175 }
176 }
177 errors.push(refusal.error(route_pattern, ir_filename));
178 render_modes.insert(route_pattern.clone(), RenderMode::Phase1ClientMount);
181 }
182 }
183 }
184
185 if !loaded.is_empty() {
186 tracing::info!(
187 ssr_routes = loaded.len(),
188 "Phase 2 SSR enabled for {} route(s)",
189 loaded.len()
190 );
191 }
192
193 (render_modes, loaded, errors)
194}
195
196fn load_ir_modules_inner<A: Embed>(
198 manifest: &AssetManifest,
199 log_fallbacks: bool,
200) -> (
201 HashMap<String, RenderMode>,
202 HashMap<String, IrModule>,
203 Vec<IrLoadError>,
204) {
205 let (render_modes, loaded, errors) = load_from(manifest, &embedded::<A>, log_fallbacks);
206 let ir_modules = loaded.into_iter().map(|l| (l.route, l.module)).collect();
207 (render_modes, ir_modules, errors)
208}
209
210fn embedded<A: Embed>(name: &str) -> Option<Cow<'static, [u8]>> {
212 A::get(name).map(|file| file.data)
213}
214
215#[deprecated(
228 since = "0.2.2",
229 note = "serves a corrupt module's route as an empty shell for the life of the process; \
230 use `load_ir_modules_strict`, the default loader, and refuse to start on its \
231 errors. Removed in 3.0.0"
232)]
233pub fn load_ir_modules<A: Embed>(
234 manifest: &AssetManifest,
235) -> (HashMap<String, RenderMode>, HashMap<String, IrModule>) {
236 log_deprecation_once();
237 let (render_modes, ir_modules, _errors) = load_ir_modules_inner::<A>(manifest, true);
238 (render_modes, ir_modules)
239}
240
241fn log_deprecation_once() {
246 #[cfg(debug_assertions)]
247 {
248 use std::sync::atomic::{AtomicBool, Ordering};
249 static LOGGED: AtomicBool = AtomicBool::new(false);
250 if !LOGGED.swap(true, Ordering::Relaxed) {
251 tracing::warn!(
252 "forma_server::load_ir_modules is deprecated: it serves a corrupt module's route \
253 as an empty shell; use load_ir_modules_strict and refuse to start on its errors. \
254 Logged once per process, in debug builds."
255 );
256 }
257 }
258}
259
260#[allow(clippy::type_complexity)]
292pub fn load_ir_modules_strict<A: Embed>(
293 manifest: &AssetManifest,
294) -> Result<(HashMap<String, RenderMode>, HashMap<String, IrModule>), Vec<IrLoadError>> {
295 let (render_modes, ir_modules, errors) = load_ir_modules_inner::<A>(manifest, false);
296 if errors.is_empty() {
297 Ok((render_modes, ir_modules))
298 } else {
299 Err(errors)
300 }
301}
302
303pub struct IrModules<A> {
374 modules: Modules,
375 assets: PhantomData<fn() -> A>,
376}
377
378impl<A: Embed> IrModules<A> {
379 pub fn load(manifest: &AssetManifest) -> Result<Self, Vec<IrLoadError>> {
388 Ok(IrModules {
389 modules: Modules::load(manifest, &embedded::<A>)?,
390 assets: PhantomData,
391 })
392 }
393
394 pub fn get(&self, route_pattern: &str) -> Option<Arc<IrModule>> {
399 self.modules.get(route_pattern, &embedded::<A>)
400 }
401}
402
403impl<A> fmt::Debug for IrModules<A> {
404 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405 f.debug_struct("IrModules")
406 .field("routes", &self.modules.routes())
407 .finish()
408 }
409}
410
411struct Modules {
414 #[cfg(not(debug_assertions))]
415 by_route: HashMap<String, Arc<IrModule>>,
416 #[cfg(debug_assertions)]
417 dev: std::sync::Mutex<dev::Pages>,
418}
419
420impl Modules {
421 fn load(manifest: &AssetManifest, read: Read<'_>) -> Result<Self, Vec<IrLoadError>> {
422 let (_, loaded, errors) = load_from(manifest, read, false);
423 if !errors.is_empty() {
424 return Err(errors);
425 }
426 #[cfg(not(debug_assertions))]
427 let modules = Modules {
428 by_route: loaded
429 .into_iter()
430 .map(|l| (l.route, Arc::new(l.module)))
431 .collect(),
432 };
433 #[cfg(debug_assertions)]
434 let modules = Modules {
435 dev: std::sync::Mutex::new(dev::Pages::new(manifest, loaded, read)),
436 };
437 Ok(modules)
438 }
439
440 #[cfg(not(debug_assertions))]
442 fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
443 let _ = read;
444 self.by_route.get(route).cloned()
445 }
446
447 #[cfg(debug_assertions)]
449 fn get(&self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
450 let mut pages = self.dev.lock().unwrap_or_else(|p| p.into_inner());
453 pages.get(route, read)
454 }
455
456 fn routes(&self) -> Vec<String> {
457 #[cfg(not(debug_assertions))]
458 let mut routes: Vec<String> = self.by_route.keys().cloned().collect();
459 #[cfg(debug_assertions)]
460 let mut routes = self.dev.lock().unwrap_or_else(|p| p.into_inner()).routes();
461 routes.sort();
462 routes
463 }
464}
465
466#[cfg(debug_assertions)]
468mod dev {
469 use super::{check_module, Loaded, Read};
470 use crate::AssetManifest;
471 use forma_ir::parser::IrModule;
472 use std::borrow::Cow;
473 use std::collections::HashMap;
474 use std::sync::Arc;
475
476 pub(super) struct Pages {
478 manifest: Option<Vec<u8>>,
480 names: HashMap<String, String>,
482 pages: HashMap<String, Page>,
484 }
485
486 #[derive(Default)]
487 struct Page {
488 current: Option<(String, Cow<'static, [u8]>, Arc<IrModule>)>,
490 refused: Option<(String, Option<Vec<u8>>)>,
492 }
493
494 impl Pages {
495 pub(super) fn new(manifest: &AssetManifest, loaded: Vec<Loaded>, read: Read<'_>) -> Self {
496 let names = manifest
497 .routes
498 .iter()
499 .filter_map(|(route, assets)| Some((route.clone(), assets.ir.clone()?)))
500 .collect();
501 let pages = loaded
502 .into_iter()
503 .map(|l| {
504 let page = Page {
505 current: Some((l.ir_file, l.bytes, Arc::new(l.module))),
506 refused: None,
507 };
508 (l.route, page)
509 })
510 .collect();
511 Pages {
512 manifest: read("manifest.json").map(Cow::into_owned),
515 names,
516 pages,
517 }
518 }
519
520 pub(super) fn routes(&self) -> Vec<String> {
521 self.pages
522 .iter()
523 .filter(|(_, p)| p.current.is_some())
524 .map(|(r, _)| r.clone())
525 .collect()
526 }
527
528 pub(super) fn get(&mut self, route: &str, read: Read<'_>) -> Option<Arc<IrModule>> {
529 self.reread_manifest(read);
530 let Some(file) = self.names.get(route).cloned() else {
531 return self.current(route);
532 };
533 let bytes = read(&file);
534 let page = self.pages.entry(route.to_string()).or_default();
535 let unchanged = page
536 .current
537 .as_ref()
538 .is_some_and(|(loaded_from, loaded, _)| {
539 *loaded_from == file && Some(loaded) == bytes.as_ref()
540 });
541 if unchanged {
542 page.refused = None;
545 return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
546 }
547 let attempt = (file.clone(), bytes.as_deref().map(<[u8]>::to_vec));
548 if page.refused.as_ref() == Some(&attempt) {
549 return page.current.as_ref().map(|(_, _, m)| Arc::clone(m));
551 }
552 match check_module(bytes.as_deref()) {
553 Ok(module) => {
554 tracing::info!(
555 route,
556 ir_file = %file,
557 "Reloaded the IR module the build rewrote (development)"
558 );
559 page.current = Some((file, bytes.unwrap_or_default(), Arc::new(module)));
560 page.refused = None;
561 }
562 Err(refusal) => {
563 let error = refusal.error(route, &file);
564 tracing::warn!(
565 code = error.code().unwrap_or_default(),
566 route,
567 ir_file = %file,
568 "{error}; the previous module stays until the build writes one that \
569 loads (development reload)"
570 );
571 page.refused = Some(attempt);
572 }
573 }
574 page.current.as_ref().map(|(_, _, m)| Arc::clone(m))
575 }
576
577 fn current(&self, route: &str) -> Option<Arc<IrModule>> {
578 self.pages
579 .get(route)
580 .and_then(|p| p.current.as_ref())
581 .map(|(_, _, m)| Arc::clone(m))
582 }
583
584 fn reread_manifest(&mut self, read: Read<'_>) {
588 let Some(bytes) = read("manifest.json") else {
589 return;
590 };
591 if self.manifest.as_deref() == Some(&bytes[..]) {
592 return;
593 }
594 match serde_json::from_slice::<AssetManifest>(&bytes) {
595 Ok(manifest) => {
596 for (route, assets) in manifest.routes {
597 if let Some(ir) = assets.ir {
598 self.names.insert(route, ir);
599 }
600 }
601 }
602 Err(e) => tracing::warn!(
603 "manifest.json does not parse ({e}); the module names stay as they were \
604 (development reload)"
605 ),
606 }
607 self.manifest = Some(bytes.into_owned());
608 }
609 }
610}
611
612#[cfg(test)]
613#[allow(deprecated)] mod tests {
615 use super::*;
616 use crate::types::RouteAssets;
617 use rust_embed::Embed;
618
619 #[derive(Embed)]
620 #[folder = "test_fixtures/"]
621 struct Fixtures;
622
623 fn manifest_with_ir(route: &str, ir: Option<&str>) -> AssetManifest {
624 let mut routes = HashMap::new();
625 routes.insert(
626 route.to_string(),
627 RouteAssets {
628 js: vec![],
629 css: vec![],
630 fonts: vec![],
631 total_size_br: 0,
632 budget_warn_threshold: 0,
633 ir: ir.map(str::to_string),
634 },
635 );
636 AssetManifest {
637 version: 1,
638 build_hash: "test".to_string(),
639 assets: HashMap::new(),
640 routes,
641 wasm: None,
642 }
643 }
644
645 #[test]
646 fn a_declared_but_unembedded_ir_file_is_reported_not_swallowed() {
647 let manifest = manifest_with_ir("/", Some("page.deadbeef.ir"));
650 let errors = load_ir_modules_strict::<Fixtures>(&manifest).unwrap_err();
651 assert_eq!(errors.len(), 1);
652 assert_eq!(errors[0].route, "/");
653 assert_eq!(errors[0].ir_file, "page.deadbeef.ir");
654 assert_eq!(errors[0].code(), Some("LOAD-101"));
655
656 let (render_modes, ir_modules) = load_ir_modules::<Fixtures>(&manifest);
659 assert_eq!(render_modes.get("/"), Some(&RenderMode::Phase1ClientMount));
660 assert!(ir_modules.is_empty());
661 }
662
663 #[test]
664 fn a_load_error_s_code_is_read_from_the_start_of_its_reason() {
665 let error = |reason: &str| IrLoadError {
666 route: "/".to_string(),
667 ir_file: "page.ir".to_string(),
668 reason: reason.to_string(),
669 };
670 assert_eq!(error("LOAD-102 bad magic").code(), Some("LOAD-102"));
671 assert_eq!(error("LOAD-103 out of bounds").code(), Some("LOAD-103"));
672 for reason in [
674 "bad magic",
675 "",
676 "LOAD-1 x",
677 "LOAD-1023 x",
678 "LOAD-10x y",
679 "load-102 x",
680 ] {
681 assert_eq!(error(reason).code(), None, "{reason:?}");
682 }
683 assert_eq!(
685 parse_failure_code(&forma_ir::IrError::BadMagic(*b"GMIR")),
686 "LOAD-102"
687 );
688 assert_eq!(
689 parse_failure_code(&forma_ir::IrError::UnsupportedVersion(3)),
690 "LOAD-102"
691 );
692 assert_eq!(
693 parse_failure_code(&forma_ir::IrError::UnsupportedFlags(1)),
694 "LOAD-102"
695 );
696 assert_eq!(
697 parse_failure_code(&forma_ir::IrError::InvalidOpcode(0x81)),
698 "LOAD-103"
699 );
700 }
701
702 #[test]
703 fn a_route_with_no_ir_field_is_not_an_error() {
704 let manifest = manifest_with_ir("/", None);
706 let (render_modes, ir_modules) =
707 load_ir_modules_strict::<Fixtures>(&manifest).expect("must not error");
708 assert!(render_modes.is_empty());
709 assert!(ir_modules.is_empty());
710 }
711}
712
713#[cfg(test)]
716mod reload_tests {
717 use super::*;
718 use std::sync::atomic::{AtomicUsize, Ordering};
719 use std::sync::Mutex;
720
721 #[derive(Default)]
723 struct Disk {
724 files: Mutex<HashMap<String, Vec<u8>>>,
725 reads: AtomicUsize,
726 }
727
728 impl Disk {
729 fn write(&self, name: &str, bytes: Vec<u8>) {
730 self.files
731 .lock()
732 .expect("lock")
733 .insert(name.to_string(), bytes);
734 }
735 fn remove(&self, name: &str) {
736 self.files.lock().expect("lock").remove(name);
737 }
738 fn read(&self, name: &str) -> Option<Cow<'static, [u8]>> {
739 self.reads.fetch_add(1, Ordering::SeqCst);
740 self.files
741 .lock()
742 .expect("lock")
743 .get(name)
744 .cloned()
745 .map(Cow::Owned)
746 }
747 fn reads(&self) -> usize {
748 self.reads.load(Ordering::SeqCst)
749 }
750 }
751
752 fn page(text: &str) -> Vec<u8> {
754 use forma_ir::parser::test_helpers::{
755 build_minimal_ir, encode_close_tag, encode_open_tag, encode_text,
756 };
757 let mut ops = encode_open_tag(0, &[]);
758 ops.extend_from_slice(&encode_text(1));
759 ops.extend_from_slice(&encode_close_tag(0));
760 build_minimal_ir(&["p", text], &[], &ops, &[])
761 }
762
763 fn html(module: Option<Arc<IrModule>>) -> String {
764 let module = module.expect("a module");
765 forma_ir::walk_to_html(&module, &forma_ir::SlotData::new(0)).expect("walks")
766 }
767
768 fn manifest_json(routes: &[(&str, Option<&str>)]) -> Vec<u8> {
770 let routes: serde_json::Map<String, serde_json::Value> = routes
771 .iter()
772 .map(|(route, ir)| {
773 let mut assets = serde_json::json!({
774 "js": ["app.js"], "css": [], "fonts": [],
775 "total_size_br": 0, "budget_warn_threshold": 0
776 });
777 if let Some(ir) = ir {
778 assets["ir"] = (*ir).into();
779 }
780 (route.to_string(), assets)
781 })
782 .collect();
783 serde_json::json!({"version": 1, "build_hash": "dev", "assets": {}, "routes": routes})
784 .to_string()
785 .into_bytes()
786 }
787
788 fn started(first: &str) -> (Disk, AssetManifest) {
791 let disk = Disk::default();
792 let json = manifest_json(&[("/", Some("page.ir")), ("/app", None)]);
793 disk.write("manifest.json", json.clone());
794 disk.write("page.ir", page(first));
795 let manifest = serde_json::from_slice(&json).expect("manifest");
796 (disk, manifest)
797 }
798
799 #[test]
800 fn a_release_build_reads_nothing_per_request_and_a_debug_build_rereads() {
801 let (disk, manifest) = started("first");
802 let read = |name: &str| disk.read(name);
803 let modules = Modules::load(&manifest, &read).expect("loads");
804 let after_load = disk.reads();
805 assert!(after_load >= 1, "the start reads the module");
806
807 for _ in 0..3 {
808 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
809 }
810 assert!(
811 modules.get("/app", &read).is_none(),
812 "a route with no module"
813 );
814 disk.write("page.ir", page("second"));
816 let served = html(modules.get("/", &read));
817
818 if cfg!(debug_assertions) {
819 assert_eq!(
820 served, "<p>second</p>",
821 "debug: the next get serves the rewrite"
822 );
823 assert_eq!(disk.reads(), after_load + 3 * 2 + 1 + 2);
825 } else {
826 assert_eq!(
827 served, "<p>first</p>",
828 "release: the module loaded at start"
829 );
830 assert_eq!(disk.reads(), after_load, "release: no read after start");
831 }
832 }
833
834 #[test]
835 fn the_reload_loads_through_the_strict_loader_at_start() {
836 let (disk, manifest) = started("first");
839 let mut flipped = page("first");
840 flipped[0] ^= 0xFF;
841 disk.write("page.ir", flipped);
842 let read = |name: &str| disk.read(name);
843 let errors = Modules::load(&manifest, &read).err().expect("refused");
844 assert_eq!(errors.len(), 1);
845 assert_eq!(errors[0].route, "/");
846 assert_eq!(errors[0].ir_file, "page.ir");
847 assert_eq!(errors[0].code(), Some("LOAD-102"));
848 disk.remove("page.ir");
849 let errors = Modules::load(&manifest, &read).err().expect("refused");
850 assert_eq!(errors[0].code(), Some("LOAD-101"));
851 }
852
853 #[cfg(debug_assertions)]
854 mod development {
855 use super::*;
856 use tracing::field::{Field, Visit};
857 use tracing::span::{Attributes, Id, Record};
858 use tracing::{Event, Metadata, Subscriber};
859
860 #[derive(Clone, Default)]
862 struct Warnings(Arc<Mutex<Vec<(String, String)>>>);
863
864 impl Subscriber for Warnings {
865 fn enabled(&self, _: &Metadata<'_>) -> bool {
866 true
867 }
868 fn new_span(&self, _: &Attributes<'_>) -> Id {
869 Id::from_u64(1)
870 }
871 fn record(&self, _: &Id, _: &Record<'_>) {}
872 fn record_follows_from(&self, _: &Id, _: &Id) {}
873 fn event(&self, event: &Event<'_>) {
874 #[derive(Default)]
875 struct Fields(String, String);
876 impl Visit for Fields {
877 fn record_str(&mut self, field: &Field, value: &str) {
878 if field.name() == "code" {
879 self.0 = value.to_string();
880 }
881 }
882 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
883 if field.name() == "message" {
884 self.1 = format!("{value:?}");
885 }
886 }
887 }
888 if *event.metadata().level() == tracing::Level::WARN {
889 let mut fields = Fields::default();
890 event.record(&mut fields);
891 self.0.lock().expect("lock").push((fields.0, fields.1));
892 }
893 }
894 fn enter(&self, _: &Id) {}
895 fn exit(&self, _: &Id) {}
896 }
897
898 impl Warnings {
899 fn codes(&self) -> Vec<String> {
900 self.0
901 .lock()
902 .expect("lock")
903 .iter()
904 .map(|(c, _)| c.clone())
905 .collect()
906 }
907 }
908
909 #[test]
910 fn a_module_that_fails_to_load_keeps_the_previous_one_and_is_logged_once_with_its_code() {
911 let (disk, manifest) = started("first");
912 let read = |name: &str| disk.read(name);
913 let modules = Modules::load(&manifest, &read).expect("loads");
914 let warnings = Warnings::default();
915 tracing::subscriber::with_default(warnings.clone(), || {
916 let good = page("second");
919 disk.write("page.ir", good[..good.len() - 3].to_vec());
920 for _ in 0..3 {
921 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
922 }
923 assert_eq!(warnings.codes(), ["LOAD-103"]);
924 let (_, message) = warnings.0.lock().expect("lock")[0].clone();
925 assert!(
926 message.contains("route /: IR file page.ir: LOAD-103"),
927 "{message}"
928 );
929 assert!(message.contains("the previous module stays"), "{message}");
930
931 let mut magic = page("second");
933 magic[0] ^= 0xFF;
934 disk.write("page.ir", magic.clone());
935 for _ in 0..3 {
936 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
937 }
938 disk.remove("page.ir");
940 for _ in 0..3 {
941 assert_eq!(html(modules.get("/", &read)), "<p>first</p>");
942 }
943 assert_eq!(warnings.codes(), ["LOAD-103", "LOAD-102", "LOAD-101"]);
944
945 disk.write("page.ir", page("second"));
947 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
948 disk.write("page.ir", magic.clone());
950 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
951 assert_eq!(
952 warnings.codes(),
953 ["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102"]
954 );
955 disk.write("page.ir", page("second"));
958 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
959 disk.write("page.ir", magic);
960 assert_eq!(html(modules.get("/", &read)), "<p>second</p>");
961 assert_eq!(
962 warnings.codes(),
963 ["LOAD-103", "LOAD-102", "LOAD-101", "LOAD-102", "LOAD-102"]
964 );
965 });
966 }
967
968 #[test]
969 fn the_manifest_can_rename_a_module_or_name_a_new_one_and_a_bad_one_changes_nothing() {
970 let (disk, manifest) = started("first");
971 let read = |name: &str| disk.read(name);
972 let modules = Modules::load(&manifest, &read).expect("loads");
973 assert!(modules.get("/late", &read).is_none());
974 let warnings = Warnings::default();
975 tracing::subscriber::with_default(warnings.clone(), || {
976 disk.write("page.2.ir", page("renamed"));
979 disk.write("late.ir", page("late"));
980 disk.write(
981 "manifest.json",
982 manifest_json(&[("/", Some("page.2.ir")), ("/late", Some("late.ir"))]),
983 );
984 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
985 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
986 assert!(warnings.codes().is_empty());
987
988 disk.write("manifest.json", b"{\"routes\":".to_vec());
990 for _ in 0..3 {
991 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
992 }
993 let logged = warnings.0.lock().expect("lock").clone();
994 assert_eq!(logged.len(), 1, "{logged:?}");
995 assert!(
996 logged[0].1.contains("manifest.json does not parse"),
997 "{logged:?}"
998 );
999
1000 disk.write("manifest.json", manifest_json(&[("/", None)]));
1003 assert_eq!(html(modules.get("/", &read)), "<p>renamed</p>");
1004 assert_eq!(html(modules.get("/late", &read)), "<p>late</p>");
1005 });
1006 }
1007 }
1008}