1use crate::manifest::{HandlerRegistration, ProtocolManifest};
39use crate::protocol::ProtocolKind;
40use serde::{Deserialize, Serialize};
41use std::path::Path;
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ScannedRoute {
54 pub route_path: String,
56 pub module_suffix: String,
58}
59
60pub fn scan_api_routes(relative_paths: &[&str]) -> Vec<ScannedRoute> {
71 let mut routes = Vec::new();
72
73 for &raw_path in relative_paths {
74 let normalized_input = raw_path.replace('\\', "/");
75 let path = Path::new(&normalized_input);
76
77 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
79 continue;
80 }
81
82 let file_stem = match path.file_stem().and_then(|s| s.to_str()) {
83 Some(stem) => stem,
84 None => continue,
85 };
86
87 if file_stem.starts_with('_') {
89 continue;
90 }
91
92 if file_stem == "mod" {
94 continue;
95 }
96
97 let without_ext = path.with_extension("");
99 let clean = without_ext.to_string_lossy().replace('\\', "/");
100
101 let route_path = format!("/{clean}");
102 let module_suffix = clean.replace('/', "::");
103
104 routes.push(ScannedRoute {
105 route_path,
106 module_suffix,
107 });
108 }
109
110 routes.sort_by(|a, b| a.route_path.cmp(&b.route_path));
111 routes
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct HandlerMeta {
124 pub name: String,
126 pub module_path: String,
128 pub request_type: String,
130 pub response_type: String,
132 pub error_type: String,
134}
135
136impl HandlerMeta {
137 pub fn from_registration(reg: &HandlerRegistration) -> Self {
139 Self {
140 name: reg.name.to_string(),
141 module_path: reg.module_path.to_string(),
142 request_type: reg.request_type.to_string(),
143 response_type: reg.response_type.to_string(),
144 error_type: reg.error_type.to_string(),
145 }
146 }
147
148 pub fn collect_all() -> Vec<Self> {
151 inventory::iter::<HandlerRegistration>
152 .into_iter()
153 .map(Self::from_registration)
154 .collect()
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct ResolvedRoute {
161 pub route_path: String,
163 pub handler_module_path: String,
165 pub handler_fn_name: String,
167 pub protocol_name: String,
169 pub kind: ProtocolKind,
171 pub surfaces: Vec<String>,
174}
175
176pub fn resolve_routes(
186 scanned: &[ScannedRoute],
187 handlers: &[HandlerMeta],
188 manifest: &ProtocolManifest,
189) -> Result<Vec<ResolvedRoute>, Vec<String>> {
190 let mut resolved = Vec::new();
191 let mut errors = Vec::new();
192
193 for route in scanned {
194 let suffix_with_sep = format!("::{}", route.module_suffix);
196 let candidates: Vec<&HandlerMeta> = handlers
197 .iter()
198 .filter(|h| {
199 h.module_path.ends_with(&suffix_with_sep) || h.module_path == route.module_suffix
200 })
201 .collect();
202
203 if candidates.is_empty() {
204 errors.push(format!(
205 "route {} has no matching handler (expected module suffix '{}')",
206 route.route_path, route.module_suffix,
207 ));
208 continue;
209 }
210
211 if candidates.len() > 1 {
212 let names: Vec<&str> = candidates.iter().map(|h| h.name.as_str()).collect();
213 errors.push(format!(
214 "route {} matches {} handlers: {}",
215 route.route_path,
216 candidates.len(),
217 names.join(", "),
218 ));
219 continue;
220 }
221
222 let handler = candidates[0];
223
224 let (kind, protocol_name, surfaces) =
226 match lookup_protocol_entry(manifest, &handler.request_type) {
227 Some(result) => result,
228 None => {
229 errors.push(format!(
230 "route {} handler '{}' has request type '{}' not found in manifest",
231 route.route_path, handler.name, handler.request_type,
232 ));
233 continue;
234 }
235 };
236
237 resolved.push(ResolvedRoute {
238 route_path: route.route_path.clone(),
239 handler_module_path: handler.module_path.clone(),
240 handler_fn_name: handler.name.clone(),
241 protocol_name,
242 kind,
243 surfaces,
244 });
245 }
246
247 let mut ident_to_route: std::collections::HashMap<String, &str> =
250 std::collections::HashMap::new();
251 for route in &resolved {
252 let ident = route_to_handler_ident(&route.route_path);
253 if let Some(existing) = ident_to_route.get(&ident) {
254 errors.push(format!(
255 "routes {} and {} both map to handler identifier '{}' — \
256 rename one to avoid collision",
257 existing, route.route_path, ident,
258 ));
259 } else {
260 ident_to_route.insert(ident, &route.route_path);
261 }
262 }
263
264 if errors.is_empty() {
265 Ok(resolved)
266 } else {
267 Err(errors)
268 }
269}
270
271fn lookup_protocol_entry(
280 manifest: &ProtocolManifest,
281 request_type: &str,
282) -> Option<(ProtocolKind, String, Vec<String>)> {
283 let type_name = request_type.rsplit("::").next().unwrap_or(request_type);
284
285 for entry in &manifest.commands {
286 if entry.name == type_name {
287 return Some((
288 ProtocolKind::Command,
289 entry.name.clone(),
290 entry.surfaces.clone(),
291 ));
292 }
293 }
294 for entry in &manifest.queries {
295 if entry.name == type_name {
296 return Some((
297 ProtocolKind::Query,
298 entry.name.clone(),
299 entry.surfaces.clone(),
300 ));
301 }
302 }
303 None
304}
305
306pub fn strip_type_prefix(qualified: &str) -> &str {
313 qualified.rsplit("::").next().unwrap_or(qualified)
314}
315
316pub fn crate_relative_handler_fn_path(module_path: &str, fn_name: &str) -> String {
322 const MARKER: &str = "::api::";
323 if let Some(pos) = module_path.find(MARKER) {
324 format!("crate::{}::{}", &module_path[pos + 2..], fn_name)
325 } else {
326 format!("{}::{}", module_path, fn_name)
327 }
328}
329
330pub fn generate_axum_routes(
350 routes: &[ResolvedRoute],
351 manifest: &ProtocolManifest,
352) -> Result<String, String> {
353 let mut out = String::new();
354
355 out.push_str("// Auto-generated by Galeon Engine — do not edit.\n");
356 out.push_str(&format!("// Protocol: {}\n\n", manifest.protocol_version));
357
358 out.push_str("use axum::extract::State;\n");
359 out.push_str("use axum::http::StatusCode;\n");
360 out.push_str("use axum::{Json, Router, routing};\n");
361 out.push_str("use galeon_engine::World;\n");
362 out.push_str("use std::sync::{Arc, Mutex};\n");
363 out.push('\n');
364
365 let surface_names = manifest.resolved_surface_names();
367
368 if surface_names.len() == 1 {
369 emit_router_fn(&mut out, "router", routes, |_| true);
371 } else {
372 let mut seen: std::collections::HashMap<String, &str> = std::collections::HashMap::new();
374 for surface in &surface_names {
375 let ident = to_rust_ident(surface);
376 if let Some(existing) = seen.get(&ident) {
377 return Err(format!(
378 "surfaces '{}' and '{}' both sanitise to identifier '{}' — \
379 rename one to avoid collision",
380 existing, surface, ident,
381 ));
382 }
383 seen.insert(ident, surface);
384 }
385
386 let default_surface = &manifest.default_surface;
388 for surface in &surface_names {
389 let fn_name = format!("{}_router", to_rust_ident(surface));
390 emit_router_fn(&mut out, &fn_name, routes, |r| {
391 route_belongs_to_surface(r, surface, default_surface)
392 });
393 }
394 }
395
396 let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
398 for route in routes {
399 let handler_name = route_to_handler_ident(&route.route_path);
400 if !emitted.insert(handler_name.clone()) {
401 continue;
402 }
403 let json_shim_path = format!(
404 "{}__galeon_axum_json",
405 crate_relative_handler_fn_path(&route.handler_module_path, &route.handler_fn_name),
406 );
407
408 out.push_str(&format!(
409 "// protocol: {proto} ({kind:?})\n\
410 async fn {handler_name}(\n\
411 \x20 State(world): State<Arc<Mutex<World>>>,\n\
412 \x20 Json(body): Json<serde_json::Value>,\n\
413 ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {{\n\
414 \x20 let json = body.to_string();\n\
415 \x20 let mut guard = world\n\
416 \x20 .lock()\n\
417 \x20 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;\n\
418 \x20 let v = {json_shim_path}(&json, &mut *guard)\n\
419 \x20 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;\n\
420 \x20 Ok(Json(v))\n\
421 }}\n\n",
422 proto = route.protocol_name,
423 kind = route.kind,
424 handler_name = handler_name,
425 json_shim_path = json_shim_path,
426 ));
427 }
428
429 Ok(out)
430}
431
432fn emit_router_fn(
434 out: &mut String,
435 fn_name: &str,
436 routes: &[ResolvedRoute],
437 filter: impl Fn(&ResolvedRoute) -> bool,
438) {
439 out.push_str(&format!(
440 "pub fn {fn_name}() -> Router<Arc<Mutex<World>>> {{\n"
441 ));
442 out.push_str(" Router::new()\n");
443 for route in routes.iter().filter(|r| filter(r)) {
444 let handler_name = route_to_handler_ident(&route.route_path);
445 out.push_str(&format!(
446 " .route({}, routing::post({handler_name}))\n",
447 quote_str(&route.route_path),
448 ));
449 }
450 out.push_str("}\n\n");
451}
452
453fn route_belongs_to_surface(route: &ResolvedRoute, surface: &str, default_surface: &str) -> bool {
456 if route.surfaces.is_empty() {
457 surface == default_surface
458 } else {
459 route.surfaces.iter().any(|s| s == surface)
460 }
461}
462
463fn to_rust_ident(s: &str) -> String {
467 let mut out: String = s
468 .chars()
469 .map(|c| {
470 if c.is_ascii_alphanumeric() || c == '_' {
471 c
472 } else {
473 '_'
474 }
475 })
476 .collect();
477 if out.starts_with(|c: char| c.is_ascii_digit()) {
478 out.insert(0, '_');
479 }
480 out
481}
482
483fn route_to_handler_ident(route_path: &str) -> String {
487 to_rust_ident(route_path.trim_start_matches('/'))
488}
489
490fn quote_str(s: &str) -> String {
492 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
493}
494
495#[cfg(test)]
500mod tests {
501 use super::*;
502 use crate::manifest::{ManifestEntry, ManifestField};
503
504 #[test]
507 fn scan_basic_route_files() {
508 let paths = &[
509 "api/fleet/dispatch.rs",
510 "api/fleet/snapshot.rs",
511 "api/physics/apply_force.rs",
512 ];
513 let routes = scan_api_routes(paths);
514 assert_eq!(routes.len(), 3);
515
516 assert_eq!(routes[0].route_path, "/api/fleet/dispatch");
517 assert_eq!(routes[0].module_suffix, "api::fleet::dispatch");
518
519 assert_eq!(routes[1].route_path, "/api/fleet/snapshot");
520 assert_eq!(routes[1].module_suffix, "api::fleet::snapshot");
521
522 assert_eq!(routes[2].route_path, "/api/physics/apply_force");
523 assert_eq!(routes[2].module_suffix, "api::physics::apply_force");
524 }
525
526 #[test]
527 fn scan_skips_underscore_prefixed_files() {
528 let paths = &[
529 "api/fleet/dispatch.rs",
530 "api/_types.rs",
531 "api/fleet/_helpers.rs",
532 ];
533 let routes = scan_api_routes(paths);
534 assert_eq!(routes.len(), 1);
535 assert_eq!(routes[0].route_path, "/api/fleet/dispatch");
536 }
537
538 #[test]
539 fn scan_skips_mod_rs() {
540 let paths = &["api/fleet/mod.rs", "api/fleet/dispatch.rs"];
541 let routes = scan_api_routes(paths);
542 assert_eq!(routes.len(), 1);
543 assert_eq!(routes[0].route_path, "/api/fleet/dispatch");
544 }
545
546 #[test]
547 fn scan_skips_non_rs_files() {
548 let paths = &[
549 "api/fleet/dispatch.rs",
550 "api/fleet/README.md",
551 "api/fleet/data.json",
552 ];
553 let routes = scan_api_routes(paths);
554 assert_eq!(routes.len(), 1);
555 }
556
557 #[test]
558 fn scan_normalizes_backslashes() {
559 let paths = &["api\\fleet\\dispatch.rs"];
560 let routes = scan_api_routes(paths);
561 assert_eq!(routes.len(), 1);
562 assert_eq!(routes[0].route_path, "/api/fleet/dispatch");
563 assert_eq!(routes[0].module_suffix, "api::fleet::dispatch");
564 }
565
566 #[test]
567 fn scan_deterministic_sort_order() {
568 let paths = &["api/zzz/last.rs", "api/aaa/first.rs", "api/mmm/middle.rs"];
569 let routes = scan_api_routes(paths);
570 assert_eq!(routes[0].route_path, "/api/aaa/first");
571 assert_eq!(routes[1].route_path, "/api/mmm/middle");
572 assert_eq!(routes[2].route_path, "/api/zzz/last");
573 }
574
575 #[test]
576 fn scan_empty_input() {
577 let routes = scan_api_routes(&[]);
578 assert!(routes.is_empty());
579 }
580
581 fn sample_handlers() -> Vec<HandlerMeta> {
584 vec![
585 HandlerMeta {
586 name: "dispatch_fleet".into(),
587 module_path: "my_game::api::fleet::dispatch".into(),
588 request_type: "DispatchFleetCmd".into(),
589 response_type: "FleetStatus".into(),
590 error_type: "String".into(),
591 },
592 HandlerMeta {
593 name: "fleet_snapshot".into(),
594 module_path: "my_game::api::fleet::snapshot".into(),
595 request_type: "GetFleetSnapshot".into(),
596 response_type: "FleetSnapshot".into(),
597 error_type: "String".into(),
598 },
599 ]
600 }
601
602 fn sample_manifest_for_routes() -> ProtocolManifest {
603 ProtocolManifest {
604 manifest_version: "2".into(),
605 protocol_version: "test@0.1".into(),
606 default_surface: "default".into(),
607 surfaces: vec!["default".into()],
608 commands: vec![ManifestEntry {
609 name: "DispatchFleetCmd".into(),
610 kind: ProtocolKind::Command,
611 fields: vec![ManifestField {
612 name: "fleet_id".into(),
613 ty: "u64".into(),
614 }],
615 doc: "".into(),
616 surfaces: vec![],
617 }],
618 queries: vec![ManifestEntry {
619 name: "GetFleetSnapshot".into(),
620 kind: ProtocolKind::Query,
621 fields: vec![],
622 doc: "".into(),
623 surfaces: vec![],
624 }],
625 events: vec![],
626 dtos: vec![],
627 }
628 }
629
630 #[test]
631 fn resolve_matches_handlers_by_module_suffix() {
632 let scanned = scan_api_routes(&["api/fleet/dispatch.rs", "api/fleet/snapshot.rs"]);
633 let handlers = sample_handlers();
634 let manifest = sample_manifest_for_routes();
635
636 let resolved = resolve_routes(&scanned, &handlers, &manifest).unwrap();
637 assert_eq!(resolved.len(), 2);
638
639 assert_eq!(resolved[0].route_path, "/api/fleet/dispatch");
640 assert_eq!(
641 resolved[0].handler_module_path,
642 "my_game::api::fleet::dispatch"
643 );
644 assert_eq!(resolved[0].handler_fn_name, "dispatch_fleet");
645 assert_eq!(resolved[0].protocol_name, "DispatchFleetCmd");
646 assert_eq!(resolved[0].kind, ProtocolKind::Command);
647 assert!(resolved[0].surfaces.is_empty()); assert_eq!(resolved[1].route_path, "/api/fleet/snapshot");
650 assert_eq!(
651 resolved[1].handler_module_path,
652 "my_game::api::fleet::snapshot"
653 );
654 assert_eq!(resolved[1].handler_fn_name, "fleet_snapshot");
655 assert_eq!(resolved[1].protocol_name, "GetFleetSnapshot");
656 assert_eq!(resolved[1].kind, ProtocolKind::Query);
657 assert!(resolved[1].surfaces.is_empty());
658 }
659
660 #[test]
661 fn resolve_errors_on_unmatched_route() {
662 let scanned = scan_api_routes(&["api/unknown/route.rs"]);
663 let handlers = sample_handlers();
664 let manifest = sample_manifest_for_routes();
665
666 let err = resolve_routes(&scanned, &handlers, &manifest).unwrap_err();
667 assert_eq!(err.len(), 1);
668 assert!(err[0].contains("no matching handler"));
669 assert!(err[0].contains("api::unknown::route"));
670 }
671
672 #[test]
673 fn resolve_errors_on_missing_manifest_entry() {
674 let scanned = scan_api_routes(&["api/fleet/dispatch.rs"]);
675 let handlers = vec![HandlerMeta {
676 name: "dispatch_fleet".into(),
677 module_path: "my_game::api::fleet::dispatch".into(),
678 request_type: "UnknownType".into(),
679 response_type: "()".into(),
680 error_type: "String".into(),
681 }];
682 let manifest = sample_manifest_for_routes();
683
684 let err = resolve_routes(&scanned, &handlers, &manifest).unwrap_err();
685 assert_eq!(err.len(), 1);
686 assert!(err[0].contains("not found in manifest"));
687 }
688
689 #[test]
690 fn resolve_carries_surface_membership() {
691 let scanned = scan_api_routes(&["api/admin/reset.rs"]);
692 let handlers = vec![HandlerMeta {
693 name: "admin_reset".into(),
694 module_path: "my_game::api::admin::reset".into(),
695 request_type: "AdminReset".into(),
696 response_type: "()".into(),
697 error_type: "String".into(),
698 }];
699 let manifest = ProtocolManifest {
700 manifest_version: "2".into(),
701 protocol_version: "test@0.1".into(),
702 default_surface: "gameplay".into(),
703 surfaces: vec!["authority".into(), "gameplay".into()],
704 commands: vec![ManifestEntry {
705 name: "AdminReset".into(),
706 kind: ProtocolKind::Command,
707 fields: vec![],
708 doc: "".into(),
709 surfaces: vec!["authority".into()],
710 }],
711 queries: vec![],
712 events: vec![],
713 dtos: vec![],
714 };
715
716 let resolved = resolve_routes(&scanned, &handlers, &manifest).unwrap();
717 assert_eq!(resolved[0].surfaces, vec!["authority".to_string()]);
718 }
719
720 fn sample_manifest_single_surface() -> ProtocolManifest {
723 sample_manifest_for_routes()
724 }
725
726 #[test]
727 fn generate_routes_contains_header() {
728 let manifest = sample_manifest_single_surface();
729 let code = generate_axum_routes(&[], &manifest).unwrap();
730 assert!(code.contains("Auto-generated by Galeon Engine"));
731 assert!(code.contains("test@0.1"));
732 }
733
734 #[test]
735 fn generate_routes_post_command() {
736 let manifest = sample_manifest_single_surface();
737 let routes = vec![ResolvedRoute {
738 route_path: "/api/fleet/dispatch".into(),
739 handler_module_path: "my_game::api::fleet::dispatch".into(),
740 handler_fn_name: "dispatch_fleet".into(),
741 protocol_name: "DispatchFleetCmd".into(),
742 kind: ProtocolKind::Command,
743 surfaces: vec![],
744 }];
745
746 let code = generate_axum_routes(&routes, &manifest).unwrap();
747 assert!(code.contains("routing::post(api_fleet_dispatch)"));
748 assert!(code.contains("\"/api/fleet/dispatch\""));
749 assert!(code.contains("Router<Arc<Mutex<World>>>"));
750 assert!(code.contains("__galeon_axum_json"));
751 assert!(code.contains("// protocol: DispatchFleetCmd"));
752 assert!(code.contains("crate::api::fleet::dispatch::dispatch_fleet__galeon_axum_json"));
753 assert!(code.contains("Json(body): Json<serde_json::Value>"));
754 assert!(code.contains("Result<Json<serde_json::Value>"));
755 }
756
757 #[test]
758 fn generate_routes_query_is_post() {
759 let manifest = sample_manifest_single_surface();
760 let routes = vec![ResolvedRoute {
761 route_path: "/api/fleet/snapshot".into(),
762 handler_module_path: "my_game::api::fleet::snapshot".into(),
763 handler_fn_name: "fleet_snapshot".into(),
764 protocol_name: "GetFleetSnapshot".into(),
765 kind: ProtocolKind::Query,
766 surfaces: vec![],
767 }];
768
769 let code = generate_axum_routes(&routes, &manifest).unwrap();
770 assert!(code.contains("routing::post(api_fleet_snapshot)"));
772 assert!(code.contains("__galeon_axum_json"));
773 assert!(code.contains("crate::api::fleet::snapshot::fleet_snapshot__galeon_axum_json"));
774 assert!(code.contains("Json(body): Json<serde_json::Value>"));
775 assert!(!code.contains("\"null\""));
776 }
777
778 #[test]
779 fn generate_routes_multiple_routes() {
780 let manifest = sample_manifest_single_surface();
781 let routes = vec![
782 ResolvedRoute {
783 route_path: "/api/fleet/dispatch".into(),
784 handler_module_path: "my_game::api::fleet::dispatch".into(),
785 handler_fn_name: "dispatch_fleet".into(),
786 protocol_name: "DispatchFleetCmd".into(),
787 kind: ProtocolKind::Command,
788 surfaces: vec![],
789 },
790 ResolvedRoute {
791 route_path: "/api/fleet/snapshot".into(),
792 handler_module_path: "my_game::api::fleet::snapshot".into(),
793 handler_fn_name: "fleet_snapshot".into(),
794 protocol_name: "GetFleetSnapshot".into(),
795 kind: ProtocolKind::Query,
796 surfaces: vec![],
797 },
798 ];
799
800 let code = generate_axum_routes(&routes, &manifest).unwrap();
801 assert!(code.contains("api_fleet_dispatch"));
802 assert!(code.contains("api_fleet_snapshot"));
803 assert!(code.contains(".route(\"/api/fleet/dispatch\""));
804 assert!(code.contains(".route(\"/api/fleet/snapshot\""));
805 }
806
807 #[test]
808 fn generate_routes_empty() {
809 let manifest = sample_manifest_single_surface();
810 let code = generate_axum_routes(&[], &manifest).unwrap();
811 assert!(code.contains("Router::new()"));
812 assert!(!code.contains(".route("));
813 }
814
815 #[test]
816 fn generate_routes_single_surface_uses_plain_router() {
817 let manifest = sample_manifest_single_surface();
818 let routes = vec![ResolvedRoute {
819 route_path: "/api/fleet/dispatch".into(),
820 handler_module_path: "my_game::api::fleet::dispatch".into(),
821 handler_fn_name: "dispatch_fleet".into(),
822 protocol_name: "DispatchFleetCmd".into(),
823 kind: ProtocolKind::Command,
824 surfaces: vec![],
825 }];
826
827 let code = generate_axum_routes(&routes, &manifest).unwrap();
828 assert!(code.contains("pub fn router()"));
829 assert!(!code.contains("_router()"));
830 }
831
832 #[test]
833 fn generate_routes_multi_surface_filters_by_surface() {
834 let manifest = ProtocolManifest {
835 manifest_version: "2".into(),
836 protocol_version: "test@0.1".into(),
837 default_surface: "gameplay".into(),
838 surfaces: vec!["authority".into(), "gameplay".into()],
839 commands: vec![],
840 queries: vec![],
841 events: vec![],
842 dtos: vec![],
843 };
844
845 let routes = vec![
846 ResolvedRoute {
847 route_path: "/api/fleet/dispatch".into(),
848 handler_module_path: "my_game::api::fleet::dispatch".into(),
849 handler_fn_name: "dispatch_fleet".into(),
850 protocol_name: "DispatchFleetCmd".into(),
851 kind: ProtocolKind::Command,
852 surfaces: vec![], },
854 ResolvedRoute {
855 route_path: "/api/admin/reset".into(),
856 handler_module_path: "my_game::api::admin::reset".into(),
857 handler_fn_name: "admin_reset".into(),
858 protocol_name: "AdminReset".into(),
859 kind: ProtocolKind::Command,
860 surfaces: vec!["authority".into()],
861 },
862 ];
863
864 let code = generate_axum_routes(&routes, &manifest).unwrap();
865
866 assert!(code.contains("pub fn authority_router()"));
868 assert!(code.contains("pub fn gameplay_router()"));
869 assert!(!code.contains("pub fn router()"));
870
871 let extract_router_body = |fn_name: &str| -> String {
873 let start = code.find(&format!("pub fn {fn_name}()")).unwrap();
874 let rest = &code[start..];
875 let end = rest.find("}\n\n").unwrap() + 1;
877 rest[..end].to_string()
878 };
879
880 let authority_body = extract_router_body("authority_router");
881 let gameplay_body = extract_router_body("gameplay_router");
882
883 assert!(authority_body.contains("api_admin_reset"));
885 assert!(!authority_body.contains("api_fleet_dispatch"));
886
887 assert!(gameplay_body.contains("api_fleet_dispatch"));
889 assert!(!gameplay_body.contains("api_admin_reset"));
890 }
891
892 #[test]
893 fn crate_relative_handler_fn_path_rewrites_api_modules() {
894 assert_eq!(
895 crate_relative_handler_fn_path("my_game::api::fleet::dispatch", "dispatch_fleet"),
896 "crate::api::fleet::dispatch::dispatch_fleet"
897 );
898 assert_eq!(
899 crate_relative_handler_fn_path("crate::api::admin::reset", "admin_reset"),
900 "crate::api::admin::reset::admin_reset"
901 );
902 }
903
904 #[test]
905 fn route_to_handler_ident_converts_paths() {
906 assert_eq!(
907 route_to_handler_ident("/api/fleet/dispatch"),
908 "api_fleet_dispatch"
909 );
910 assert_eq!(
911 route_to_handler_ident("/api/fleet-ops/dispatch"),
912 "api_fleet_ops_dispatch"
913 );
914 }
915
916 #[test]
917 fn to_rust_ident_sanitises_non_identifier_chars() {
918 assert_eq!(to_rust_ident("qa.v2"), "qa_v2");
919 assert_eq!(to_rust_ident("admin ui"), "admin_ui");
920 assert_eq!(to_rust_ident("foo-bar_baz"), "foo_bar_baz");
921 assert_eq!(to_rust_ident("plain"), "plain");
922 }
923
924 #[test]
925 fn to_rust_ident_prepends_underscore_for_leading_digit() {
926 assert_eq!(to_rust_ident("3d"), "_3d");
927 assert_eq!(to_rust_ident("0test"), "_0test");
928 assert_eq!(to_rust_ident("abc"), "abc");
929 }
930
931 #[test]
932 fn resolve_errors_on_handler_ident_collision() {
933 let scanned = scan_api_routes(&["api/foo/bar.rs", "api/foo_bar.rs"]);
935 let handlers = vec![
936 HandlerMeta {
937 name: "bar_handler".into(),
938 module_path: "my_game::api::foo::bar".into(),
939 request_type: "BarCmd".into(),
940 response_type: "()".into(),
941 error_type: "String".into(),
942 },
943 HandlerMeta {
944 name: "foo_bar_handler".into(),
945 module_path: "my_game::api::foo_bar".into(),
946 request_type: "FooBarCmd".into(),
947 response_type: "()".into(),
948 error_type: "String".into(),
949 },
950 ];
951 let manifest = ProtocolManifest {
952 manifest_version: "2".into(),
953 protocol_version: "test@0.1".into(),
954 default_surface: "default".into(),
955 surfaces: vec!["default".into()],
956 commands: vec![
957 ManifestEntry {
958 name: "BarCmd".into(),
959 kind: ProtocolKind::Command,
960 fields: vec![],
961 doc: "".into(),
962 surfaces: vec![],
963 },
964 ManifestEntry {
965 name: "FooBarCmd".into(),
966 kind: ProtocolKind::Command,
967 fields: vec![],
968 doc: "".into(),
969 surfaces: vec![],
970 },
971 ],
972 queries: vec![],
973 events: vec![],
974 dtos: vec![],
975 };
976
977 let err = resolve_routes(&scanned, &handlers, &manifest).unwrap_err();
978 assert_eq!(err.len(), 1);
979 assert!(err[0].contains("collision"));
980 assert!(err[0].contains("api_foo_bar"));
981 }
982
983 #[test]
984 fn generate_routes_multi_surface_sanitises_surface_names() {
985 let manifest = ProtocolManifest {
986 manifest_version: "2".into(),
987 protocol_version: "test@0.1".into(),
988 default_surface: "qa.v2".into(),
989 surfaces: vec!["qa.v2".into()],
990 commands: vec![],
991 queries: vec![],
992 events: vec![],
993 dtos: vec![],
994 };
995
996 let mut manifest_multi = manifest.clone();
998 manifest_multi.surfaces = vec!["qa.v2".into(), "admin ui".into()];
999
1000 let code = generate_axum_routes(&[], &manifest_multi).unwrap();
1001 assert!(code.contains("pub fn qa_v2_router()"));
1002 assert!(code.contains("pub fn admin_ui_router()"));
1003 assert!(!code.contains("pub fn qa.v2"));
1005 assert!(!code.contains("pub fn admin ui"));
1006 }
1007
1008 #[test]
1009 fn generate_routes_errors_on_surface_name_collision() {
1010 let manifest = ProtocolManifest {
1011 manifest_version: "2".into(),
1012 protocol_version: "test@0.1".into(),
1013 default_surface: "qa.v2".into(),
1014 surfaces: vec!["qa.v2".into(), "qa-v2".into()],
1015 commands: vec![],
1016 queries: vec![],
1017 events: vec![],
1018 dtos: vec![],
1019 };
1020
1021 let err = generate_axum_routes(&[], &manifest).unwrap_err();
1022 assert!(err.contains("collision"));
1023 assert!(err.contains("qa_v2"));
1024 }
1025
1026 #[test]
1027 fn generate_routes_digit_surface_produces_valid_ident() {
1028 let manifest = ProtocolManifest {
1029 manifest_version: "2".into(),
1030 protocol_version: "test@0.1".into(),
1031 default_surface: "3d".into(),
1032 surfaces: vec!["3d".into(), "ui".into()],
1033 commands: vec![],
1034 queries: vec![],
1035 events: vec![],
1036 dtos: vec![],
1037 };
1038
1039 let code = generate_axum_routes(&[], &manifest).unwrap();
1040 assert!(code.contains("pub fn _3d_router()"));
1042 assert!(code.contains("pub fn ui_router()"));
1043 }
1044}