Skip to main content

actus_server/
router.rs

1//! [`Router`] and [`RouterBuilder`] — the longest-prefix route tree that maps
2//! a request path to the controller mounted at its deepest matching prefix.
3
4use actus_controller::{Controller, Params, RouteDef};
5use actus_reply::{Reply, WebError};
6use std::collections::HashMap;
7use std::sync::Arc;
8use tracing::{debug, warn};
9
10/// A node in the routing tree, representing a segment of a URL path.
11#[derive(Default)]
12struct RouteNode {
13    /// Child nodes for sub-paths (e.g., "api" -> "v2").
14    children: HashMap<String, RouteNode>,
15    /// A controller mounted at this exact path. Because routing is
16    /// longest-prefix, a mounted controller also handles every path *below*
17    /// it that no deeper mount claims — it receives the unconsumed path as
18    /// its "action". So a single mount at `"foo"` serves `/foo`, `/foo/x`,
19    /// `/foo/x/y`, … unless something is mounted deeper.
20    controller: Option<Arc<dyn Controller>>,
21}
22
23/// The result of [`Router::match_controller`]: the matched controller and
24/// the leftover path segments joined as the controller's action. Returned
25/// from a path-only resolve (no verb / parameter checks; those happen
26/// inside the controller's dispatch).
27#[derive(Clone)]
28pub struct RouteMatch {
29    /// The controller mounted at the matched longest prefix.
30    pub controller: Arc<dyn Controller>,
31    /// The unconsumed path below that prefix, joined with `/` — the
32    /// controller's "action".
33    pub action: String,
34}
35
36/// One controller's declared rate-limit class, as returned by
37/// [`Router::rate_limit_classes`]: the controller's `mount` path and the
38/// `class` label it declared via `#[controller(rate_limit = "…")]`. Used by a
39/// startup coverage check that asserts every declared class has a policy.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct RateLimitClass {
42    /// The controller's mount path (e.g. `"api/auth"`).
43    pub mount: String,
44    /// The rate-limit class label the controller declared.
45    pub class: &'static str,
46}
47
48/// One mounted controller's declarations — a row of [`Router::mounts`], the
49/// per-mount inventory.
50///
51/// Framework-populated: application code reads these rows, it never
52/// constructs them — which is why the struct is `#[non_exhaustive]` and can
53/// grow new fields in minor releases. Destructure with `..` or read fields
54/// directly.
55///
56/// Unlike [`Router::rate_limit_classes`], the inventory emits a row for
57/// **every** mounted controller: a controller that declared nothing appears
58/// with `expects: None` / `prepare: None`, because for route-family coverage
59/// the *omission* is the interesting case and has to be representable before
60/// it can be caught.
61#[non_exhaustive]
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct Mount {
64    /// The controller's mount path — slash-joined segments, no leading or
65    /// trailing slash; `""` for a root (or `"*"`) mount. Same convention as
66    /// [`Router::routes`].
67    pub mount: String,
68    /// The controller's type name ([`Controller::__name`]).
69    pub controller: &'static str,
70    /// The declared caller expectation — the controller's *floor* — from
71    /// `#[controller(expects = "…")]`. `None` means the controller declared
72    /// nothing.
73    pub expects: Option<&'static str>,
74    /// The `prepare` hook's written path (e.g. `"Self::auth"`) from
75    /// `#[controller(prepare = …)]`, or `None` when there is no hook.
76    /// Presence is the payload; the string is for route dumps.
77    pub prepare: Option<&'static str>,
78    /// The declared rate-limit class from `#[controller(rate_limit = "…")]`.
79    pub rate_limit_class: Option<&'static str>,
80    /// The per-controller body cap from `#[controller(max_body_bytes = …)]`;
81    /// `None` means the controller defers to the server-wide cap.
82    pub max_body_bytes: Option<usize>,
83}
84
85/// The Actus router. Dispatches requests to controllers by longest-prefix
86/// match over the route tree at arbitrary depth.
87pub struct Router {
88    root: RouteNode,
89}
90
91impl Router {
92    /// Find which controller a path would hit and the leftover-segments
93    /// action, without invoking the controller. Returns `None` if no
94    /// controller matches (→ 404).
95    ///
96    /// The verb-level resolution (which `routes!` line the action will
97    /// dispatch to, or 405 if none match the verb) happens inside the
98    /// controller's `actus_dispatch`; this method only does the
99    /// path-tree walk.
100    pub fn match_controller(&self, path_parts: &[String]) -> Option<RouteMatch> {
101        let mut current_node = &self.root;
102        // (controller, prefix_len) — `prefix_len` is how many leading path
103        // segments the matched controller's mount point consumed.
104        let mut longest_match: Option<(Arc<dyn Controller>, usize)> = None;
105
106        // A controller mounted at the root ("" or "*") handles "/" and, as
107        // the loop below shows, anything not claimed by a deeper mount.
108        if let Some(ref controller) = current_node.controller {
109            longest_match = Some((controller.clone(), 0));
110        }
111
112        for (i, segment) in path_parts.iter().enumerate() {
113            match current_node.children.get(segment) {
114                Some(child_node) => {
115                    current_node = child_node;
116                    if let Some(ref controller) = current_node.controller {
117                        longest_match = Some((controller.clone(), i + 1));
118                    }
119                }
120                // The path diverges from the tree here; the deepest mount we
121                // passed through (held in `longest_match`) takes the rest.
122                None => break,
123            }
124        }
125
126        longest_match.map(|(controller, prefix_len)| {
127            // Action is the joined path remaining after the matched
128            // controller's mount point — all of it, so multi-segment
129            // routes like `"posts/{id}/comments"` (and `{...rest}` params)
130            // can match. Fully-consumed path → action `""`, which is how
131            // a controller registers a root handler via `routes! { "" => … }`.
132            let action: String = if prefix_len >= path_parts.len() {
133                String::new()
134            } else {
135                path_parts[prefix_len..].join("/")
136            };
137            RouteMatch { controller, action }
138        })
139    }
140
141    /// Routes a request to the appropriate controller and action: thin
142    /// wrapper around [`Router::match_controller`] that also invokes
143    /// `actus_dispatch`. Kept for callers (mostly tests) that don't need
144    /// to inspect the matched controller before dispatch — the server
145    /// uses the two-step shape so it can buffer the body with the right
146    /// cap *between* match and dispatch.
147    pub async fn route(&self, path_parts: &[String], params: Params) -> Reply {
148        match self.match_controller(path_parts) {
149            Some(rm) => {
150                debug!(action = %rm.action, "routing to controller");
151                rm.controller.actus_dispatch(&rm.action, params).await
152            }
153            None => {
154                debug!(?path_parts, "no route matched");
155                Err(WebError::NotFound)
156            }
157        }
158    }
159
160    /// Walk the route tree and return every `(mount_path, RouteDef)` pair.
161    ///
162    /// `mount_path` is the slash-joined path *segments* where the controller
163    /// was mounted (no leading or trailing slash) — `""` for a root mount,
164    /// `"api/users"` for `app_routes!{ "api/users" => UserController, ... }`,
165    /// etc. Each controller contributes one entry per `RouteDef` it
166    /// describes via [`Controller::actus_describe_routes`].
167    ///
168    /// Used by introspection tools (OpenAPI doc generators, route audit
169    /// scripts). Order matches a DFS over the route tree; within a single
170    /// controller, routes are emitted in the order
171    /// `Controller::actus_describe_routes` returns them (which is the order
172    /// they were declared in the `routes!` block).
173    pub fn routes(&self) -> Vec<(String, RouteDef)> {
174        let mut out = Vec::new();
175        let mut prefix: Vec<String> = Vec::new();
176        Self::walk(&self.root, &mut prefix, &mut out);
177        out
178    }
179
180    fn walk(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<(String, RouteDef)>) {
181        if let Some(controller) = &node.controller {
182            let mount = prefix.join("/");
183            for rd in controller.actus_describe_routes() {
184                out.push((mount.clone(), rd));
185            }
186        }
187        // Sorting the children gives a deterministic traversal order across
188        // runs (HashMap iteration is otherwise nondeterministic), which
189        // matters for stable OpenAPI output / test assertions.
190        let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
191        children.sort_by(|a, b| a.0.cmp(b.0));
192        for (seg, child) in children {
193            prefix.push(seg.clone());
194            Self::walk(child, prefix, out);
195            prefix.pop();
196        }
197    }
198
199    /// Walk the route tree and return `(mount_path, class)` for every mounted
200    /// controller that declared a rate-limit class via
201    /// `#[controller(rate_limit = "…")]`. Controllers with no class are
202    /// skipped. `mount_path` follows the same convention as [`Router::routes`]
203    /// (`""` for a root mount); order is a deterministic DFS (children sorted),
204    /// so diagnostics and tests get stable output.
205    ///
206    /// This exposes the *declared* half of the rate-limit picture — the half
207    /// only the router knows. An application's rate-limit middleware holds the
208    /// other half (the classes it has a *policy* for), so `main()` can diff the
209    /// two at startup and assert every declared class is covered. That turns a
210    /// typo'd class (`"ath"` for `"auth"`) into a boot failure instead of a
211    /// silently-unlimited controller. One tree walk; no per-request cost.
212    ///
213    /// ⚠️ **Emptiness asymmetry.** This method catches a *misspelling* and is
214    /// blind to an *omission*: a controller that declared no class produces no
215    /// row here, so nothing can notice its absence. For rate limiting that is
216    /// usually fine (an unlimited controller is usually intended). When the
217    /// omission itself is the failure you're checking for — route-family
218    /// coverage — use [`Router::mounts`], which emits a row for every mounted
219    /// controller, declared or not. (This method keeps its historical shape;
220    /// `mounts()` is the absence-inclusive inventory.)
221    pub fn rate_limit_classes(&self) -> Vec<RateLimitClass> {
222        let mut out = Vec::new();
223        let mut prefix: Vec<String> = Vec::new();
224        Self::walk_classes(&self.root, &mut prefix, &mut out);
225        out
226    }
227
228    fn walk_classes(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<RateLimitClass>) {
229        if let Some(controller) = &node.controller
230            && let Some(class) = controller.actus_rate_limit()
231        {
232            out.push(RateLimitClass {
233                mount: prefix.join("/"),
234                class,
235            });
236        }
237        let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
238        children.sort_by(|a, b| a.0.cmp(b.0));
239        for (seg, child) in children {
240            prefix.push(seg.clone());
241            Self::walk_classes(child, prefix, out);
242            prefix.pop();
243        }
244    }
245
246    /// Walk the route tree and return one [`Mount`] row per mounted
247    /// controller — the per-mount inventory: mount path, controller name,
248    /// declared caller expectation (*floor*), `prepare` hook presence,
249    /// rate-limit class, and body cap.
250    ///
251    /// **Absence is a row, not a skip.** A controller that declared nothing
252    /// appears with `expects: None`, which is what makes a route-family
253    /// coverage check possible: the check's job is precisely to catch the
254    /// controller that silently declared nothing under a prefix whose other
255    /// controllers all did. (Contrast [`Router::rate_limit_classes`], which
256    /// emits only declaring controllers.)
257    ///
258    /// Order is a deterministic DFS (children sorted), matching
259    /// [`Router::routes`]; `mount` follows the same convention (`""` for a
260    /// root mount). One tree walk; no per-request cost. Typical use: a
261    /// startup coverage check in `main()`, a route dump, or building a
262    /// `mount → floor` map for a declaration-keyed gate — see the README's
263    /// "Route families" section.
264    pub fn mounts(&self) -> Vec<Mount> {
265        let mut out = Vec::new();
266        let mut prefix: Vec<String> = Vec::new();
267        Self::walk_mounts(&self.root, &mut prefix, &mut out);
268        out
269    }
270
271    fn walk_mounts(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<Mount>) {
272        if let Some(controller) = &node.controller {
273            out.push(Mount {
274                mount: prefix.join("/"),
275                controller: controller.__name(),
276                expects: controller.actus_expects(),
277                prepare: controller.actus_prepare(),
278                rate_limit_class: controller.actus_rate_limit(),
279                max_body_bytes: controller.actus_max_body_bytes(),
280            });
281        }
282        let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
283        children.sort_by(|a, b| a.0.cmp(b.0));
284        for (seg, child) in children {
285            prefix.push(seg.clone());
286            Self::walk_mounts(child, prefix, out);
287            prefix.pop();
288        }
289    }
290}
291
292/// A builder for constructing the router.
293#[derive(Default)]
294pub struct RouterBuilder {
295    root: RouteNode,
296}
297
298impl RouterBuilder {
299    /// Create an empty builder with no mounts.
300    pub fn new() -> Self {
301        Self::default()
302    }
303
304    /// Adds a route, creating the necessary nodes in the tree.
305    ///
306    /// A **trailing `*` segment is optional sugar** for mounting the
307    /// controller at the prefix before it: `"foo/*"` routes identically to
308    /// `"foo"`, and `"*"` identically to `""` (the root) — a mounted
309    /// controller already receives the entire unconsumed path as its action,
310    /// so a "catch-all" is just a mount. The `*` is there so a reader of
311    /// `app_routes!` can see "this controller is the catch-all here" at a
312    /// glance. A `*` anywhere but the last segment is meaningless; such a
313    /// route is dropped with a warning.
314    pub fn add_route(mut self, path: &str, controller: Arc<dyn Controller>) -> Self {
315        let path = path.trim_matches('/');
316        let parts: Vec<&str> = if path.is_empty() {
317            Vec::new()
318        } else {
319            path.split('/').collect()
320        };
321
322        // Drop a trailing `*` (it's sugar — see the doc comment).
323        let segments: &[&str] = match parts.split_last() {
324            Some((last, head)) if *last == "*" => head,
325            _ => parts.as_slice(),
326        };
327
328        if segments.contains(&"*") {
329            warn!(
330                route = path,
331                "'*' is only meaningful as the last segment of a route path; ignoring route"
332            );
333            return self;
334        }
335
336        let mut current_node = &mut self.root;
337        for part in segments {
338            current_node = current_node
339                .children
340                .entry((*part).to_string())
341                .or_default();
342        }
343        // Surface accidental double-mounts loudly instead of silently
344        // letting the later registration overwrite the earlier one. The
345        // most common cause is two `app_routes!` entries with the same
346        // path literal — easy to introduce when copy-pasting versioned
347        // mounts (`api/v1/x`, `api/v2/x`, …).
348        if let Some(prev) = &current_node.controller {
349            warn!(
350                route = path,
351                previous = prev.__name(),
352                new = controller.__name(),
353                "duplicate route mount: the later controller overwrites the earlier one",
354            );
355        }
356        current_node.controller = Some(controller);
357        self
358    }
359
360    /// Finalize the builder into an immutable [`Router`].
361    pub fn build(self) -> Router {
362        Router { root: self.root }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use actus_controller::Verb;
370    use bytes::Bytes;
371    use std::sync::Mutex;
372
373    /// `(mounted path, action)` pairs recorded by [`Spy`] controllers.
374    type SpyLog = Arc<Mutex<Vec<(String, String)>>>;
375
376    /// A controller that records `(its registered path, the action it got)`
377    /// into a shared log instead of doing real work, so a test can assert
378    /// which controller a request reached and with what action.
379    struct Spy {
380        path: String,
381        log: SpyLog,
382    }
383
384    #[actus_controller::async_trait]
385    impl Controller for Spy {
386        async fn actus_dispatch(&self, action: &str, _params: Params) -> Reply {
387            self.log
388                .lock()
389                .unwrap()
390                .push((self.path.clone(), action.to_string()));
391            Err(WebError::NotFound) // return value is irrelevant to these tests
392        }
393        fn __name(&self) -> &'static str {
394            "spy"
395        }
396    }
397
398    fn build(routes: &[&str]) -> (Router, SpyLog) {
399        let log = Arc::new(Mutex::new(Vec::new()));
400        let mut b = RouterBuilder::new();
401        for r in routes {
402            b = b.add_route(
403                r,
404                Arc::new(Spy {
405                    path: r.to_string(),
406                    log: log.clone(),
407                }),
408            );
409        }
410        (b.build(), log)
411    }
412
413    fn empty_params() -> Params {
414        Params::new(
415            Verb::GET,
416            HashMap::new(),
417            None,
418            Bytes::new(),
419            HashMap::new(),
420        )
421    }
422
423    /// Returns `Some((mounted_path, action))` for the controller the request
424    /// reached, or `None` if nothing matched (→ 404).
425    async fn hit(router: &Router, log: &SpyLog, path: &str) -> Option<(String, String)> {
426        log.lock().unwrap().clear();
427        let pp: Vec<String> = path
428            .trim_matches('/')
429            .split('/')
430            .filter(|s| !s.is_empty())
431            .map(String::from)
432            .collect();
433        let _ = router.route(&pp, empty_params()).await;
434        log.lock().unwrap().last().cloned()
435    }
436
437    fn at(path: &str, action: &str) -> Option<(String, String)> {
438        Some((path.to_string(), action.to_string()))
439    }
440
441    #[tokio::test]
442    async fn longest_prefix_wins_and_passes_the_remainder() {
443        let (r, log) = build(&["api/users", "api/users/admin"]);
444        assert_eq!(hit(&r, &log, "/api/users").await, at("api/users", ""));
445        assert_eq!(hit(&r, &log, "/api/users/42").await, at("api/users", "42"));
446        assert_eq!(
447            hit(&r, &log, "/api/users/42/posts").await,
448            at("api/users", "42/posts")
449        );
450        // a deeper mount takes precedence over a shallower one
451        assert_eq!(
452            hit(&r, &log, "/api/users/admin").await,
453            at("api/users/admin", "")
454        );
455        assert_eq!(
456            hit(&r, &log, "/api/users/admin/x").await,
457            at("api/users/admin", "x")
458        );
459        // nothing mounted at or above `/api` alone
460        assert_eq!(hit(&r, &log, "/api").await, None);
461        assert_eq!(hit(&r, &log, "/nope").await, None);
462        assert_eq!(hit(&r, &log, "/").await, None);
463    }
464
465    #[tokio::test]
466    async fn trailing_star_is_sugar_for_mounting_at_the_prefix() {
467        let (star, log_s) = build(&["api/folder/*"]);
468        let (plain, log_p) = build(&["api/folder"]);
469        for path in ["/api/folder", "/api/folder/x", "/api/folder/x/y/z"] {
470            assert_eq!(
471                hit(&star, &log_s, path).await.map(|(_, a)| a),
472                hit(&plain, &log_p, path).await.map(|(_, a)| a),
473                "`api/folder/*` and `api/folder` must route `{path}` identically"
474            );
475        }
476        // and in particular the bare prefix is matched (not a 404)
477        assert_eq!(hit(&star, &log_s, "/api/folder").await.unwrap().1, "");
478        assert_eq!(
479            hit(&star, &log_s, "/api/folder/deep/path").await.unwrap().1,
480            "deep/path"
481        );
482    }
483
484    #[tokio::test]
485    async fn root_star_is_the_global_fallback() {
486        let (r, log) = build(&["*", "api/users"]);
487        // specific routes still win
488        assert_eq!(hit(&r, &log, "/api/users").await, at("api/users", ""));
489        assert_eq!(hit(&r, &log, "/api/users/9").await, at("api/users", "9"));
490        // everything else falls to the root catch-all, with the full path
491        assert_eq!(hit(&r, &log, "/").await, at("*", ""));
492        assert_eq!(
493            hit(&r, &log, "/anything/here").await,
494            at("*", "anything/here")
495        );
496        // including a divergence partway down a known prefix
497        assert_eq!(hit(&r, &log, "/api/missing").await, at("*", "api/missing"));
498        // `""` is equivalent to `"*"`
499        let (r2, log2) = build(&["", "api/users"]);
500        assert_eq!(hit(&r2, &log2, "/whatever").await, at("", "whatever"));
501        assert_eq!(hit(&r2, &log2, "/api/users").await, at("api/users", ""));
502    }
503
504    #[test]
505    fn match_controller_returns_some_for_matched_path_and_none_for_404() {
506        // Build a router with two mounts. match_controller walks the tree
507        // and returns the deepest mounted controller's match — or None
508        // if the path doesn't reach any mount.
509        let log = Arc::new(Mutex::new(Vec::new()));
510        let router = RouterBuilder::new()
511            .add_route(
512                "api/users",
513                Arc::new(Spy {
514                    path: "users".to_string(),
515                    log: log.clone(),
516                }),
517            )
518            .add_route(
519                "api/users/admin",
520                Arc::new(Spy {
521                    path: "admin".to_string(),
522                    log: log.clone(),
523                }),
524            )
525            .build();
526
527        // Deeper mount wins for the deeper path.
528        let pp: Vec<String> = vec!["api".into(), "users".into(), "admin".into()];
529        let rm = router.match_controller(&pp).expect("matches");
530        assert_eq!(rm.action, "");
531        assert_eq!(rm.controller.__name(), "spy");
532
533        // Shallower mount catches the unconsumed remainder.
534        let pp: Vec<String> = vec!["api".into(), "users".into(), "42".into()];
535        let rm = router.match_controller(&pp).expect("matches");
536        assert_eq!(rm.action, "42");
537
538        // Diverged from the tree → None (404 territory).
539        let pp: Vec<String> = vec!["api".into(), "missing".into()];
540        assert!(router.match_controller(&pp).is_none());
541
542        // Empty path with nothing mounted at root → None.
543        let pp: Vec<String> = vec![];
544        assert!(router.match_controller(&pp).is_none());
545    }
546
547    #[tokio::test]
548    async fn duplicate_mount_overwrites_earlier_one() {
549        // Two controllers registered at the same path — the second wins.
550        // (A `tracing::warn!` fires too; we don't assert on it here, but
551        // the override behavior is what callers actually observe.)
552        let log = Arc::new(Mutex::new(Vec::new()));
553        let first = Arc::new(Spy {
554            path: "first".to_string(),
555            log: log.clone(),
556        });
557        let second = Arc::new(Spy {
558            path: "second".to_string(),
559            log: log.clone(),
560        });
561        let router = RouterBuilder::new()
562            .add_route("api/users", first)
563            .add_route("api/users", second)
564            .build();
565        let pp: Vec<String> = vec!["api".into(), "users".into()];
566        let _ = router.route(&pp, empty_params()).await;
567        assert_eq!(
568            log.lock().unwrap().last().map(|(p, _)| p.as_str()),
569            Some("second"),
570        );
571    }
572
573    /// A `Controller` that exposes a fixed slice of `RouteDef`s via
574    /// `actus_describe_routes()` — used to exercise `Router::routes()`
575    /// without depending on the `#[controller]` macro.
576    struct Described {
577        routes: &'static [RouteDef],
578    }
579
580    #[actus_controller::async_trait]
581    impl Controller for Described {
582        async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
583            Err(WebError::NotFound)
584        }
585        fn __name(&self) -> &'static str {
586            "described"
587        }
588        fn actus_describe_routes(&self) -> Vec<RouteDef> {
589            self.routes.to_vec()
590        }
591    }
592
593    #[tokio::test]
594    async fn routes_introspection_returns_mount_paths_and_routedefs() {
595        static USERS_ROUTES: &[RouteDef] = &[
596            RouteDef {
597                pattern: "",
598                handler_id: "handler_0",
599                handler: "list",
600                verb: &[Verb::GET],
601                params: &[],
602                doc: None,
603            },
604            RouteDef {
605                pattern: "{id}",
606                handler_id: "handler_1",
607                handler: "get",
608                verb: &[Verb::GET],
609                params: &[],
610                doc: None,
611            },
612        ];
613        static HEALTH_ROUTES: &[RouteDef] = &[RouteDef {
614            pattern: "",
615            handler_id: "handler_0",
616            handler: "ping",
617            verb: actus_controller::DEFAULT_VERBS,
618            params: &[],
619            doc: None,
620        }];
621
622        let router = RouterBuilder::new()
623            .add_route(
624                "api/users",
625                Arc::new(Described {
626                    routes: USERS_ROUTES,
627                }),
628            )
629            .add_route(
630                "health",
631                Arc::new(Described {
632                    routes: HEALTH_ROUTES,
633                }),
634            )
635            .build();
636
637        let pairs = router.routes();
638        // 2 routes from api/users + 1 from health = 3 total.
639        let mounts: Vec<&str> = pairs.iter().map(|(m, _)| m.as_str()).collect();
640        let handlers: Vec<&str> = pairs.iter().map(|(_, r)| r.handler).collect();
641        assert_eq!(
642            mounts,
643            vec!["api/users", "api/users", "health"],
644            "DFS sorts child segments alphabetically — 'api' before 'health'",
645        );
646        assert_eq!(handlers, vec!["list", "get", "ping"]);
647    }
648
649    /// A controller that declares a rate-limit class — overrides
650    /// `actus_rate_limit` the way the `#[controller(rate_limit = …)]` macro
651    /// would, without depending on the macro in this crate.
652    struct Classed(&'static str);
653
654    #[actus_controller::async_trait]
655    impl Controller for Classed {
656        async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
657            Err(WebError::NotFound)
658        }
659        fn __name(&self) -> &'static str {
660            "classed"
661        }
662        fn actus_rate_limit(&self) -> Option<&'static str> {
663            Some(self.0)
664        }
665    }
666
667    #[test]
668    fn rate_limit_classes_lists_only_classed_controllers_with_mounts() {
669        // Two classed controllers and one unclassed (`Spy`, default `None`).
670        // The walk returns the classed ones with their mounts, in sorted-DFS
671        // order ('auth' < 'health' < 'tasks'), and omits the unclassed one.
672        let log = Arc::new(Mutex::new(Vec::new()));
673        let router = RouterBuilder::new()
674            .add_route("api/auth", Arc::new(Classed("auth")))
675            .add_route(
676                "api/health",
677                Arc::new(Spy {
678                    path: "health".into(),
679                    log: log.clone(),
680                }),
681            )
682            .add_route("api/tasks", Arc::new(Classed("tasks")))
683            .build();
684
685        assert_eq!(
686            router.rate_limit_classes(),
687            vec![
688                RateLimitClass {
689                    mount: "api/auth".to_string(),
690                    class: "auth",
691                },
692                RateLimitClass {
693                    mount: "api/tasks".to_string(),
694                    class: "tasks",
695                },
696            ],
697        );
698        // The unclassed controller contributes nothing.
699        assert!(
700            router
701                .rate_limit_classes()
702                .iter()
703                .all(|rlc| rlc.mount != "api/health"),
704        );
705    }
706
707    /// A controller that declares everything — overrides the metadata
708    /// methods the way the `#[controller(...)]` macro would, without
709    /// depending on the macro in this crate.
710    struct Declared;
711
712    #[actus_controller::async_trait]
713    impl Controller for Declared {
714        async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
715            Err(WebError::NotFound)
716        }
717        fn __name(&self) -> &'static str {
718            "declared"
719        }
720        fn actus_expects(&self) -> Option<&'static str> {
721            Some("credential")
722        }
723        fn actus_prepare(&self) -> Option<&'static str> {
724            Some("Self::auth")
725        }
726        fn actus_rate_limit(&self) -> Option<&'static str> {
727            Some("auth")
728        }
729        fn actus_max_body_bytes(&self) -> Option<usize> {
730            Some(4096)
731        }
732    }
733
734    #[test]
735    fn mounts_emits_a_row_for_every_controller_including_undeclared() {
736        // THE regression test for the blind spot the inventory exists to fix:
737        // `rate_limit_classes()` skips controllers that declared nothing, so
738        // an omission is invisible there. `mounts()` must emit a row for the
739        // undeclared controller (`Spy` overrides no metadata method), with
740        // every declaration field `None` — absence as data, not as a skip.
741        let log = Arc::new(Mutex::new(Vec::new()));
742        let router = RouterBuilder::new()
743            .add_route("api/things", Arc::new(Declared))
744            .add_route(
745                "api/undeclared",
746                Arc::new(Spy {
747                    path: "undeclared".into(),
748                    log: log.clone(),
749                }),
750            )
751            .build();
752
753        assert_eq!(
754            router.mounts(),
755            vec![
756                Mount {
757                    mount: "api/things".to_string(),
758                    controller: "declared",
759                    expects: Some("credential"),
760                    prepare: Some("Self::auth"),
761                    rate_limit_class: Some("auth"),
762                    max_body_bytes: Some(4096),
763                },
764                Mount {
765                    mount: "api/undeclared".to_string(),
766                    controller: "spy",
767                    expects: None,
768                    prepare: None,
769                    rate_limit_class: None,
770                    max_body_bytes: None,
771                },
772            ],
773            "one row per mounted controller, sorted-DFS order, absences included",
774        );
775    }
776
777    #[test]
778    fn mounts_reports_a_root_mount_as_the_empty_string() {
779        // Same mount-path convention as `routes()`: a `"*"` (or `""`) mount
780        // is the root, reported as `""` — the coverage-check convention
781        // `mount.split('/').next()` then yields `""`, which no family
782        // prefix matches, so a root catch-all is naturally unconstrained.
783        let log = Arc::new(Mutex::new(Vec::new()));
784        let router = RouterBuilder::new()
785            .add_route(
786                "*",
787                Arc::new(Spy {
788                    path: "spa".into(),
789                    log: log.clone(),
790                }),
791            )
792            .build();
793        let rows = router.mounts();
794        assert_eq!(rows.len(), 1);
795        assert_eq!(rows[0].mount, "");
796        assert_eq!(rows[0].expects, None);
797    }
798
799    #[tokio::test]
800    async fn star_only_in_last_position() {
801        // A `*` that isn't the final segment is meaningless; the route is
802        // dropped (with a warning to stderr) rather than treating `*` as a
803        // literal segment.
804        let (r, log) = build(&["a/*/b"]);
805        assert_eq!(hit(&r, &log, "/a/x/b").await, None);
806        assert_eq!(hit(&r, &log, "/a/*/b").await, None);
807    }
808}