Skip to main content

galeon_engine/
route_scanner.rs

1// SPDX-License-Identifier: AGPL-3.0-only OR Commercial
2
3//! Filesystem-routed API scanning and axum glue generation.
4//!
5//! Walks an `api/` directory to discover route files, matches them against
6//! [`HandlerRegistration`] entries collected via `inventory`, and emits
7//! `generated/routes.rs` — an axum `Router` that delegates through the
8//! discovered `#[handler]` functions with JSON bodies against
9//! `Arc<std::sync::Mutex<galeon_engine::World>>` state via a per-route shim
10//! that calls each handler's autogenerated `name__galeon_axum_json` shim (emitted
11//! by `#[handler]` with explicit [`IntoHandler`][crate::handler_function::IntoHandler]
12//! type arguments so ECS [`SystemParam`][crate::system_param::SystemParam] lists
13//! type-check).
14//!
15//! # Data flow
16//!
17//! ```text
18//! CLI walks api/         HandlerRegistration    ProtocolManifest
19//!       │                  (inventory)              (inventory)
20//!       ▼                       │                       │
21//!  scan_api_routes()            │                       │
22//!       │                       ▼                       ▼
23//!       └──────────►  resolve_routes()  ◄───────────────┘
24//!                           │
25//!                           ▼
26//!                  generate_axum_routes()
27//!                           │
28//!                           ▼
29//!                   generated/routes.rs
30//! ```
31//!
32//! Schema always comes from [`ProtocolManifest`]; HTTP execution calls the
33//! resolved handler function directly instead of routing through
34//! [`HandlerRegistry`][crate::handler::HandlerRegistry]. The scanner discovers
35//! *exposure* — which handlers are reachable via HTTP — without re-deriving
36//! protocol schema.
37
38use crate::manifest::{HandlerRegistration, ProtocolManifest};
39use crate::protocol::ProtocolKind;
40use serde::{Deserialize, Serialize};
41use std::path::Path;
42
43// =============================================================================
44// T1: Filesystem scanning and path normalization
45// =============================================================================
46
47/// A discovered route file from the `api/` directory.
48///
49/// Produced by [`scan_api_routes`] from a list of relative file paths.
50/// Each entry maps a source file to its HTTP route path and the Rust module
51/// path suffix used to match against [`HandlerRegistration::module_path`].
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct ScannedRoute {
54    /// HTTP route path (e.g., `"/api/fleet/dispatch"`).
55    pub route_path: String,
56    /// Module path suffix for handler matching (e.g., `"api::fleet::dispatch"`).
57    pub module_suffix: String,
58}
59
60/// Scan a list of relative file paths and produce normalized route entries.
61///
62/// `relative_paths` must be relative to the project root and use forward
63/// slashes (e.g., `"api/fleet/dispatch.rs"`). The function:
64///
65/// - Keeps only `.rs` files
66/// - Skips files whose stem starts with `_` (helper modules, not routes)
67/// - Skips `mod.rs` (module root, not an endpoint)
68/// - Normalizes `\` to `/` for cross-platform compatibility
69/// - Sorts results by route path for deterministic output
70pub 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        // Must have .rs extension.
78        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        // Skip _-prefixed helper files.
88        if file_stem.starts_with('_') {
89            continue;
90        }
91
92        // Skip mod.rs — module root, not a route endpoint.
93        if file_stem == "mod" {
94            continue;
95        }
96
97        // Strip .rs extension, normalize separators.
98        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// =============================================================================
115// T2 + T3: Route resolution — join scanner, handlers, and manifest
116// =============================================================================
117
118/// Owned handler metadata extracted from [`HandlerRegistration`].
119///
120/// `HandlerRegistration` uses `&'static str` fields (baked in by macros).
121/// This owned copy is serializable and decoupled from the `inventory` lifetime.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct HandlerMeta {
124    /// Handler function name (e.g., `"dispatch_fleet"`).
125    pub name: String,
126    /// Full module path (e.g., `"my_game::api::fleet::dispatch"`).
127    pub module_path: String,
128    /// Request type name (e.g., `"DispatchFleetCmd"`).
129    pub request_type: String,
130    /// Response type name (e.g., `"FleetStatus"`).
131    pub response_type: String,
132    /// Error type name (e.g., `"FleetError"`).
133    pub error_type: String,
134}
135
136impl HandlerMeta {
137    /// Convert a static [`HandlerRegistration`] into an owned [`HandlerMeta`].
138    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    /// Collect all [`HandlerRegistration`] entries from `inventory` into
149    /// owned [`HandlerMeta`] values.
150    pub fn collect_all() -> Vec<Self> {
151        inventory::iter::<HandlerRegistration>
152            .into_iter()
153            .map(Self::from_registration)
154            .collect()
155    }
156}
157
158/// A fully resolved route with all metadata needed for code generation.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct ResolvedRoute {
161    /// HTTP route path (e.g., `"/api/fleet/dispatch"`).
162    pub route_path: String,
163    /// `module_path!()` at the `#[handler]` site (e.g., `"crate::api::fleet::dispatch"`).
164    pub handler_module_path: String,
165    /// Handler function name (e.g., `"dispatch_fleet"`).
166    pub handler_fn_name: String,
167    /// Protocol name of the request type (e.g., `"DispatchFleetCmd"`).
168    pub protocol_name: String,
169    /// Protocol kind — command vs query (metadata; handlers are invoked directly).
170    pub kind: ProtocolKind,
171    /// Explicit surface memberships from the manifest entry.
172    /// Empty means "default surface only" — same semantics as [`ManifestEntry::surfaces`].
173    pub surfaces: Vec<String>,
174}
175
176/// Match scanned routes to handler registrations and manifest entries.
177///
178/// For each [`ScannedRoute`], finds the matching [`HandlerMeta`] by checking
179/// whether `handler.module_path` ends with the route's `module_suffix`.
180/// Then looks up the handler's `request_type` in the manifest to determine
181/// the protocol kind and surface membership.
182///
183/// Returns resolved routes on success, or a list of diagnostic messages
184/// for unmatched routes or ambiguous matches.
185pub 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        // Find handlers whose module_path ends with the route's module suffix.
195        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        // Look up the request type in the manifest to determine kind + surfaces.
225        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    // Detect handler identifier collisions (e.g., api/foo/bar.rs vs api/foo_bar.rs
248    // both mapping to `api_foo_bar`).
249    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
271/// Look up a request type name in the manifest.
272///
273/// Returns the protocol kind, canonical manifest name, and surface memberships.
274///
275/// The `request_type` from [`HandlerRegistration`] may include a path prefix
276/// (e.g., `"crate::SpawnUnit"`) because the `#[handler]` macro preserves the
277/// type path as written in source. We match against the last segment of the
278/// path to find the corresponding manifest entry.
279fn 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
306/// Strip any module path prefix from a request type name.
307///
308/// `"crate::SpawnUnit"` → `"SpawnUnit"`, `"SpawnUnit"` → `"SpawnUnit"`.
309///
310/// Shared between route resolution and handler validation so both
311/// entrypoints behave consistently.
312pub fn strip_type_prefix(qualified: &str) -> &str {
313    qualified.rsplit("::").next().unwrap_or(qualified)
314}
315
316/// Build a `crate::...::fn_name` path for generated `routes.rs` included in the protocol crate.
317///
318/// [`HandlerRegistration::module_path`] comes from `module_path!()` (for example
319/// `my_game::api::fleet::dispatch`). The generated file is `include!`'d next to
320/// those modules, so the crate prefix is rewritten to `crate::`.
321pub 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
330// =============================================================================
331// T4: Axum glue code generation
332// =============================================================================
333
334/// Generate the `routes.rs` axum glue source code from resolved routes.
335///
336/// The generated code creates per-surface axum `Router` functions with
337/// `Arc<std::sync::Mutex<galeon_engine::World>>` as state. Each route uses POST,
338/// deserializes JSON, and invokes the handler's `__galeon_axum_json` shim (see
339/// the `#[handler]` macro), which boxes the handler with an explicit
340/// `IntoHandler::<Req, Resp, Params>` instantiation.
341///
342/// All routes are POST — the manifest cannot distinguish unit structs
343/// (`null`) from empty named structs (`{}`), so GET with a hardcoded
344/// payload would break one or the other at runtime.
345///
346/// For single-surface manifests the function is `pub fn router()`.
347/// For multi-surface manifests each surface gets its own function
348/// (e.g., `pub fn gameplay_router()`).
349pub 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    // Per-surface router functions.
366    let surface_names = manifest.resolved_surface_names();
367
368    if surface_names.len() == 1 {
369        // Single surface — generate a plain `router()`.
370        emit_router_fn(&mut out, "router", routes, |_| true);
371    } else {
372        // Detect surface name collisions after sanitization.
373        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        // Multi-surface — generate `<surface>_router()` per surface.
387        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    // Handler functions — shared across surfaces, deduplicated.
397    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
432/// Emit a single router function containing the routes that pass `filter`.
433fn 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
453/// Check whether a resolved route belongs to a surface, using the same
454/// semantics as [`ProtocolManifest::entry_belongs_to_surface`].
455fn 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
463/// Convert an arbitrary string to a valid Rust identifier by replacing
464/// every non-alphanumeric, non-underscore character with `_` and
465/// prepending `_` if the result starts with a digit.
466fn 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
483/// Convert a route path to a valid Rust function identifier.
484///
485/// `/api/fleet/dispatch` → `api_fleet_dispatch`
486fn route_to_handler_ident(route_path: &str) -> String {
487    to_rust_ident(route_path.trim_start_matches('/'))
488}
489
490/// Quote a string as a Rust string literal.
491fn quote_str(s: &str) -> String {
492    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
493}
494
495// =============================================================================
496// Tests
497// =============================================================================
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use crate::manifest::{ManifestEntry, ManifestField};
503
504    // -- T1: scan_api_routes --
505
506    #[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    // -- T2 + T3: resolve_routes --
582
583    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()); // inherits default
648
649        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    // -- T4: generate_axum_routes --
721
722    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        // All routes are POST — avoids unit-struct vs empty-named-struct ambiguity.
771        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![], // default surface = gameplay
853            },
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        // Per-surface router functions.
867        assert!(code.contains("pub fn authority_router()"));
868        assert!(code.contains("pub fn gameplay_router()"));
869        assert!(!code.contains("pub fn router()"));
870
871        // Extract each router function body (up to closing `}`).
872        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            // The function ends at the first `}\n\n` (closing brace + blank line).
876            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        // authority_router contains only AdminReset
884        assert!(authority_body.contains("api_admin_reset"));
885        assert!(!authority_body.contains("api_fleet_dispatch"));
886
887        // gameplay_router contains only DispatchFleetCmd
888        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        // api/foo/bar.rs and api/foo_bar.rs both map to `api_foo_bar`.
934        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        // Two surfaces needed for multi-surface codegen path.
997        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        // No raw dots or spaces in function names.
1004        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        // Must not start with a digit.
1041        assert!(code.contains("pub fn _3d_router()"));
1042        assert!(code.contains("pub fn ui_router()"));
1043    }
1044}