Skip to main content

frust/
router_glue.rs

1//! Router ⇄ deep-link glue: the facade is the ONLY crate
2//! that sees both `frust-widgets`' [`Router`] and `frust-reactive`'s
3//! deep-link source together — `frust-widgets` stays reactive-free (see
4//! `Router::handle_location`'s doc) and `frust-reactive` stays router-free
5//! (see `frust_reactive::deep_link`'s module docs).
6//!
7//! # Precedence
8//!
9//! A cold-start deep link always wins over an app-hardcoded
10//! `initial_location` — a deliberate choice (go_router's
11//! `initialLocation`-vs-link precedence is itself a judgment call upstream;
12//! we choose deep-link-wins because a cold-start link represents where the OS
13//! actually opened the app, which should never lose to a hardcoded default).
14//!
15//! # Path navigation (the `RouteNavigator` pump)
16//!
17//! [`RouterDeepLinks::track`] also **pumps the router's
18//! [`RouteNavigator`]** — the `Send + Sync` queue a screen reaches through
19//! `provide_context` (see `frust_widgets::nav::route`'s module docs for why the
20//! router itself cannot ride context). This crate is where the two halves meet
21//! for the same reason as the deep-link half: [`RouterDeepLinks::new`] installs
22//! `ReactiveRuntime::wake` as the queue's waker, so an off-thread request
23//! schedules a frame, and `track()`'s `pump()` applies it before the app's
24//! `build` returns — an app already calling `track()` every build gets path
25//! navigation with **no new app code**.
26//!
27//! # Dedupe
28//!
29//! [`RouterDeepLinks::track`] is meant to be called from every
30//! `Component::build` (rebuild re-runs `build` far more often than a new link
31//! actually arrives — see `docs/ARCHITECTURE.md`'s Component state boundary).
32//! It tracks the live [`DeepLinks::latest`](frust_reactive::DeepLinks::latest)
33//! signal but only calls [`Router::handle_location`] when the observed link's
34//! [`DeepLink::sequence`](frust_reactive::DeepLink::sequence) differs from
35//! the last one it consumed — the marker
36//! is the delivered *sequence*, not the link itself, so a rebuild re-run that
37//! observes the same already-consumed delivery is a no-op, while a repeated
38//! identical URL (a genuinely new delivery, carrying a new sequence) still
39//! navigates — see this module's tests for the exact contract.
40
41use std::cell::Cell;
42use std::rc::Rc;
43use std::sync::Arc;
44
45use frust_reactive::{ReactiveRuntime, deep_links};
46use frust_widgets::{RouteNavigator, Router};
47use reactive_graph::traits::{Get, GetUntracked};
48
49use crate::route_state::RouteObserver;
50
51/// Normalize a raw deep-link source string into a router-ready path.
52/// Platform shells push the RAW URL as delivered by the
53/// OS (`fktest://item/7`, or `https://host/item/7`) into the deep-link
54/// source, but the router only understands bare paths —
55/// `frust_widgets::nav::path::Location::parse` splits on `/` with no
56/// scheme awareness, so an unnormalized `fktest://item/7` becomes the bogus
57/// path `/fktest:/item/7`. Per this module's own docs (top of file), this
58/// crate is the one place that sees both the router and the deep-link
59/// source, so the URL→path translation lives here — hand-rolled and
60/// dependency-free (no `url` crate in the workspace), mirroring
61/// `path.rs`'s own precedent.
62///
63/// Rules:
64/// 1. No `://` in the string → already a bare path; pass through unchanged
65///    (bare-path sources like navdemo's simulate-deep-link button, plus
66///    defense-in-depth).
67/// 2. Has `://` → split `scheme://rest`:
68///    - `http`/`https` (universal/App Links): drop the host — keep from the
69///      first `/` of `rest` onward (`https://host/item/7` → `/item/7`; no
70///      `/` at all, e.g. `https://host`, → `/`).
71///    - Any other (custom) scheme: map host+path
72///      (`fktest://item/7` → `/item/7`, host `item` + `/7`; a hostless
73///      `fktest:///settings` → `/settings`).
74/// 3. The result always starts with `/`.
75///
76/// Graceful on unmappable input by construction — worst case it produces `/`
77/// or an unmatched path, which `Router::handle_location` already routes to
78/// the error page like any other unmatched location (see this module's
79/// tests' Criterion 3); it never panics.
80fn normalize_deep_link(raw: &str) -> String {
81    let Some((scheme, rest)) = raw.split_once("://") else {
82        // Rule 1.
83        return raw.to_string();
84    };
85
86    if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
87        // Rule 2, universal/App Links: drop the host.
88        return match rest.find('/') {
89            Some(idx) => rest[idx..].to_string(),
90            None => "/".to_string(),
91        };
92    }
93
94    // Rule 2, custom scheme: map host+path. `rest` is `host/path...` — or,
95    // for a hostless `scheme:///path`, `/path...` (an empty host before the
96    // first `/`).
97    let (host, tail) = match rest.split_once('/') {
98        Some((h, t)) => (h, Some(t)),
99        None => (rest, None),
100    };
101    match (host.is_empty(), tail) {
102        (true, Some(t)) if !t.is_empty() => format!("/{t}"),
103        (true, _) => "/".to_string(),
104        (false, Some(t)) if !t.is_empty() => format!("/{host}/{t}"),
105        (false, _) => format!("/{host}"),
106    }
107}
108
109/// A [`Router`] wired to the process-wide deep-link source (see the module
110/// docs). Construct once with [`router_with_deep_links`] (or
111/// [`RouterDeepLinks::new`] directly) — typically from `Component::init`,
112/// storing the result in `Component::State` — then call
113/// [`track`](Self::track) from every `Component::build` to keep navigating on
114/// subsequent warm links.
115pub struct RouterDeepLinks<State: 'static> {
116    router: Router<State>,
117    /// The [`DeepLink::sequence`](frust_reactive::DeepLink::sequence) of the
118    /// last delivery this instance has handled, if any. Compared against —
119    /// never against the link's URL or the link itself — so a rebuild
120    /// re-observing the same delivery is a no-op while a repeated identical
121    /// URL (a new delivery, new sequence) still navigates.
122    consumed: Rc<Cell<Option<u64>>>,
123    /// The route-state observable wired to this router's navigator — see
124    /// [`routes`](Self::routes).
125    routes: RouteObserver,
126}
127
128impl<State: 'static> RouterDeepLinks<State> {
129    /// Wire `router` to the deep-link source and resolve its start location
130    /// **once**: the process's cold-start link if one has already arrived
131    /// (see [`DeepLinks::initial`](frust_reactive::DeepLinks::initial)),
132    /// else `initial_location` (see the module docs' precedence). Drives
133    /// `router` there immediately via [`Router::handle_location`].
134    ///
135    /// Call this exactly once per router instance (e.g. from
136    /// `Component::init`) — calling it again re-navigates to the start
137    /// location, discarding whatever the app already navigated to since.
138    pub fn new(router: Router<State>, initial_location: &str) -> Self {
139        // The router's request queue is reactive-free by construction
140        // (`frust-widgets` names no `reactive_graph` symbol), so the facade
141        // installs the wake callback: a request queued from a background
142        // thread, or from an event handler after this frame's rebuild, must
143        // still schedule a frame for `track()`'s pump to run in.
144        router.route_navigator().set_waker(Arc::new(|| {
145            if let Some(runtime) = ReactiveRuntime::get() {
146                runtime.wake();
147            }
148        }));
149
150        let links = deep_links();
151        let start = links
152            .initial
153            .clone()
154            .unwrap_or_else(|| initial_location.to_string());
155        router.handle_location(&normalize_deep_link(&start));
156
157        // If a cold-start link resolved the start location above, seed the
158        // consumed marker with the SEQUENCE of the real delivery that
159        // produced it (read from `latest`, never minted with
160        // `DeepLink::new` — that would assign a sequence the actual delivery
161        // never carried, so the first `track()` would see it as new and
162        // re-navigate). A cold-start push sets both `initial` and `latest`
163        // together (see `frust_reactive::deep_link`'s module docs), so as
164        // long as `latest` still holds that same URL, its sequence is the
165        // one to seed with. If a *different*, newer link has already arrived
166        // by the time this constructor runs, `latest`'s URL will have moved
167        // on — leave the marker at `None` so the first `track()` navigates
168        // to that newer link exactly as it would have before this change.
169        let consumed = Rc::new(Cell::new(links.initial.as_deref().and_then(
170            |initial_url| {
171                links
172                    .latest
173                    .get_untracked()
174                    .filter(|link| link.url == initial_url)
175                    .map(|link| link.sequence)
176            },
177        )));
178
179        Self {
180            router,
181            consumed,
182            routes: RouteObserver::new(),
183        }
184    }
185
186    /// Apply everything queued on the router's
187    /// [`RouteNavigator`](Self::route_navigator), then track the live
188    /// deep-link signal and navigate the router to any link that hasn't been
189    /// consumed yet. Call from every `Component::build` — the pump is
190    /// idempotent on an empty queue, and the link half is dedup'd by a consumed
191    /// marker (see the module docs), so a rebuild re-run that observes the same
192    /// already-handled link is a no-op: no re-navigation, no panic even on a
193    /// malformed/unmatched link (routed to the router's error page like any
194    /// other unmatched location).
195    ///
196    /// Pumping first is what makes a queued request **zero-frame**: `build`
197    /// runs before the navigator's own `rebuild`, so the controller ops this
198    /// enqueues are drained in that very same reconcile pass.
199    pub fn track(&self) {
200        self.router.pump();
201
202        let Some(link) = deep_links().latest.get() else {
203            return;
204        };
205        let already_consumed = self.consumed.get() == Some(link.sequence);
206        if already_consumed {
207            return;
208        }
209        self.consumed.set(Some(link.sequence));
210        self.router.handle_location(&normalize_deep_link(&link.url));
211    }
212
213    /// The wired router — hand its [`controller()`](Router::controller) to
214    /// [`navigator`](frust_widgets::navigator), or call its navigation
215    /// methods directly.
216    pub fn router(&self) -> &Router<State> {
217        &self.router
218    }
219
220    /// The router's [`RouteNavigator`], waker already installed. `Send + Sync`
221    /// and cheap to clone, so a `Component::init` can publish it once
222    /// (`provide_context(state.nav.route_navigator())`) and any screen — or
223    /// background task — navigates by path without holding the router:
224    ///
225    /// ```ignore
226    /// let nav = use_context::<RouteNavigator>().expect("provided at the root");
227    /// button("Open", move |_| nav.push("/terminal?session=abc"));
228    /// ```
229    ///
230    /// Requests land on the next [`track`](Self::track).
231    pub fn route_navigator(&self) -> RouteNavigator {
232        self.router.route_navigator()
233    }
234
235    /// The route-state observable wired to this router — attach it to
236    /// the router-driven navigator with
237    /// `.observe(navigator(links.router().controller(), ...))`:
238    ///
239    /// ```ignore
240    /// state.router_links.routes().observe(navigator(&c, initial))
241    /// ```
242    ///
243    /// `Copy`, so calling this more than once (or storing the result
244    /// alongside `route_navigator()`, e.g. under `provide_context`) hands out
245    /// clones of the same signals — reads through any of them see the same
246    /// published state.
247    pub fn routes(&self) -> RouteObserver {
248        self.routes
249    }
250}
251
252/// Convenience constructor equivalent to [`RouterDeepLinks::new`] — see its
253/// docs for the precedence rule and the call-once contract.
254///
255/// ```no_run
256/// use frust::{AnyView, Component, Route, Router, RouterDeepLinks, View, any, navigator, text};
257///
258/// // A route builder is *stored* (`Rc<dyn Fn(..) -> AnyView<_>>`), so it
259/// // erases with `any(..)`; everywhere else erasure is implicit.
260///
261/// #[derive(Default)]
262/// struct App;
263///
264/// struct AppState {
265///     router_links: RouterDeepLinks<AppState>,
266/// }
267///
268/// impl Component for App {
269///     type State = AppState;
270///
271///     fn init(&self) -> AppState {
272///         let router: Router<AppState> =
273///             Router::new(vec![Route::new("/", |_params| -> AnyView<AppState> {
274///                 any(text("home"))
275///             })]);
276///         // Resolves the start location once: a cold-start deep link wins
277///         // over "/" if one already arrived.
278///         AppState {
279///             router_links: frust::router_with_deep_links(router, "/"),
280///         }
281///     }
282///
283///     fn build(&self, state: &mut AppState) -> impl View<AppState> {
284///         // Called every rebuild: navigates on a new warm link, no-ops
285///         // otherwise (see the module docs' dedupe contract).
286///         state.router_links.track();
287///         let controller = state.router_links.router().controller();
288///         navigator(controller, || text("home"))
289///     }
290/// }
291///
292/// frust::app!(App);
293/// # fn main() {}
294/// ```
295pub fn router_with_deep_links<State: 'static>(
296    router: Router<State>,
297    initial_location: &str,
298) -> RouterDeepLinks<State> {
299    RouterDeepLinks::new(router, initial_location)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use frust_core::{FrameTime, PaintScene, RenderRoot, View};
306    use frust_reactive::{ReactiveRuntime, push_deep_link};
307    use frust_widgets::{NavigatorView, Route, navigator};
308    use kurbo::{Point, Size};
309    use std::sync::Arc;
310
311    // --- A GPU/text-free sized leaf, mirroring `router.rs`'s own test
312    // fixtures, so a route's page can be distinguished by its painted size
313    // without touching the `Text` widget (which panics without a threaded
314    // `TextContext` — see `frust_core::widget::LayoutCtx::text_context`).
315
316    struct SizedLeaf {
317        size: Size,
318    }
319    struct SizedLeafWidget {
320        size: Size,
321    }
322    impl View<()> for SizedLeaf {
323        type Element = SizedLeafWidget;
324        fn build(&self, _ctx: &mut frust_core::BuildCtx<'_>) -> SizedLeafWidget {
325            SizedLeafWidget { size: self.size }
326        }
327        fn rebuild(
328            &self,
329            _prev: &Self,
330            element: &mut SizedLeafWidget,
331            _ctx: &mut frust_core::BuildCtx<'_>,
332        ) -> frust_core::ChangeFlags {
333            element.size = self.size;
334            frust_core::ChangeFlags::LAYOUT
335        }
336    }
337    impl frust_core::Widget for SizedLeafWidget {
338        fn layout(
339            &mut self,
340            _ctx: &mut frust_core::LayoutCtx,
341            bc: &frust_core::BoxConstraints,
342        ) -> Size {
343            bc.constrain(self.size)
344        }
345        fn paint(&mut self, ctx: &mut frust_core::PaintCtx, scene: &mut dyn PaintScene) {
346            scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
347        }
348    }
349    fn sized(w: f64, h: f64) -> impl View<()> {
350        SizedLeaf {
351            size: Size::new(w, h),
352        }
353    }
354
355    #[derive(Default)]
356    struct RecordingScene {
357        rects: Vec<(Point, Size)>,
358    }
359    impl PaintScene for RecordingScene {
360        fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
361            self.rects.push((origin, size));
362        }
363        fn draw_text(&mut self, _origin: Point, _text: &str) {}
364    }
365
366    const HOME: Size = Size::new(10.0, 10.0);
367    const PROFILE: Size = Size::new(20.0, 20.0);
368    const SETTINGS: Size = Size::new(30.0, 30.0);
369    const ERROR: Size = Size::new(99.0, 99.0);
370
371    fn routes() -> Vec<Route<()>> {
372        vec![
373            Route::new("/", |_params| sized(HOME.width, HOME.height)),
374            Route::new("/profile/:id", |_params| {
375                sized(PROFILE.width, PROFILE.height)
376            }),
377            Route::new("/settings", |_params| {
378                sized(SETTINGS.width, SETTINGS.height)
379            }),
380        ]
381    }
382
383    fn router_with_error_leaf() -> Router<()> {
384        Router::new(routes()).error_builder(|_loc| sized(ERROR.width, ERROR.height))
385    }
386
387    /// The build closure type [`RenderRoot::rebuild`] drives, boxed so
388    /// [`Harness`] can store it as a field.
389    type AppLogic = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
390
391    /// A `RouterDeepLinks` plus the `RenderRoot`/`app` closure driving its
392    /// controller's `navigator`, all built once and reused across a test's
393    /// assertions — mirrors `router.rs`'s own tests: a fresh `RenderRoot` per
394    /// assertion would lose the controller's already-drained op history.
395    struct Harness {
396        links: RouterDeepLinks<()>,
397        root: RenderRoot<(), NavigatorView<()>>,
398        app: AppLogic,
399        state: (),
400    }
401
402    impl Harness {
403        fn new(router: Router<()>, initial_location: &str) -> Self {
404            let links = RouterDeepLinks::new(router, initial_location);
405            let controller = links.router().controller().clone();
406            let app: AppLogic = Box::new(move |_: &mut ()| {
407                navigator(&controller, || sized(HOME.width, HOME.height))
408            });
409            let mut harness = Harness {
410                links,
411                root: RenderRoot::new(),
412                app,
413                state: (),
414            };
415            harness.rebuild();
416            harness
417        }
418
419        fn rebuild(&mut self) {
420            self.root.rebuild(&mut self.app, &mut self.state);
421        }
422
423        /// Track the deep-link signal, rebuild, and return the size of the
424        /// page currently painted on top of the stack.
425        fn track_and_paint(&mut self) -> Size {
426            self.links.track();
427            self.rebuild();
428            self.paint()
429        }
430
431        fn paint(&mut self) -> Size {
432            self.root.layout(Size::new(100.0, 100.0));
433            let mut scene = RecordingScene::default();
434            self.root.paint(&mut scene, FrameTime::ZERO);
435            scene
436                .rects
437                .last()
438                .map(|(_, size)| *size)
439                .unwrap_or(Size::ZERO)
440        }
441    }
442
443    // All four acceptance criteria live in ONE `#[test]` function,
444    // deliberately: `frust_reactive::deep_link`'s process-wide `SLOT` (in
445    // particular `DeepLinks::initial`, "the first link ever pushed in this
446    // process" — see its module docs) is shared by every test in this binary,
447    // and this is the only test in `frust`'s suite that touches it. A
448    // single function gives deterministic ordering (no cross-test race on
449    // that global) without needing a crate-private test lock like
450    // `frust_reactive::deep_link`'s own tests use (`WAKER_TEST_LOCK` is
451    // `pub(crate)`, unreachable from here) — and this test asserts nothing
452    // about waker/wake-count, the one thing that lock actually protects.
453    #[test]
454    fn router_deep_link_glue() {
455        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
456
457        // Criterion 1a: no link queued yet anywhere in this process — a fresh
458        // router starts at `initial_location`, not any stale state.
459        let mut cold_no_link = Harness::new(router_with_error_leaf(), "/");
460        assert_eq!(cold_no_link.paint(), HOME);
461
462        // Criterion 1b: a deep link queued before the router is constructed
463        // wins over `initial_location` — this is also, deliberately, the
464        // FIRST push in this test binary, so it becomes `DeepLinks::initial`
465        // for the rest of this test (and the process).
466        push_deep_link("/profile/42");
467        let mut warm = Harness::new(router_with_error_leaf(), "/");
468        assert_eq!(warm.paint(), PROFILE);
469
470        // Criterion 5a (cold-start dedupe, the regression this task fixes):
471        // the constructor seeds the consumed marker from the SEQUENCE of the
472        // real cold-start delivery (read from `latest`), not a freshly minted
473        // one — so navigate away by hand, then call `track()` with no new
474        // push, and the router must stay put rather than re-navigating back
475        // to `/profile/42`. Against the pre-fix code (which minted a new
476        // `DeepLink` — and therefore a new sequence — to seed the marker)
477        // this assertion fails: the freshly minted sequence never equals the
478        // real delivery's, so `track()` treats the cold-start link as
479        // unconsumed and calls `handle_location` again.
480        warm.links.router().go("/");
481        warm.rebuild();
482        assert_eq!(warm.paint(), HOME);
483        assert_eq!(
484            warm.track_and_paint(),
485            HOME,
486            "track() must not re-navigate to the cold-start link on the first call"
487        );
488
489        // Criterion 5b (repeat delivery): pushing the identical URL again is
490        // a genuinely new delivery (a new sequence), so it must navigate
491        // again rather than being treated as already consumed.
492        push_deep_link("/profile/42");
493        assert_eq!(
494            warm.track_and_paint(),
495            PROFILE,
496            "an identical URL delivered again must still navigate"
497        );
498        warm.links.router().go("/");
499        warm.rebuild();
500        assert_eq!(warm.paint(), HOME);
501
502        // Criterion 2: a warm link pushed during a running session navigates
503        // on the next `track()`.
504        push_deep_link("/settings");
505        assert_eq!(warm.track_and_paint(), SETTINGS);
506
507        // Criterion 2 (dedupe): manually navigate elsewhere, then call
508        // `track()` again with no new push — the same link must not be
509        // re-handled (it would jump back to SETTINGS if it were).
510        warm.links.router().go("/");
511        warm.rebuild();
512        assert_eq!(warm.paint(), HOME);
513        assert_eq!(
514            warm.track_and_paint(),
515            HOME,
516            "track() must not re-navigate to an already-consumed link"
517        );
518
519        // Criterion 3: a malformed/unmatched link hits the router's error
520        // page — no panic.
521        push_deep_link("/does/not/exist");
522        assert_eq!(warm.track_and_paint(), ERROR);
523
524        // Criterion 4 (the `RouteNavigator` pump): a request queued the way a
525        // screen's event handler would — through the context-safe handle, with
526        // no router in sight — is applied by the very next `track()`, with no
527        // new deep link involved. Asserted inside this same function for the
528        // same process-wide-slot reason as the criteria above: `track()` reads
529        // `latest`, whose value is only known here.
530        warm.links.route_navigator().go("/settings");
531        assert_eq!(
532            warm.track_and_paint(),
533            SETTINGS,
534            "track() must pump the RouteNavigator queue"
535        );
536
537        // ...and pumping an empty queue on every subsequent build is inert.
538        warm.links.router().go("/");
539        warm.rebuild();
540        assert_eq!(warm.track_and_paint(), HOME);
541    }
542
543    /// The context round-trip contract: a [`RouteNavigator`] round-trips
544    /// through `provide_context`/`use_context` (which require `Send + Sync +
545    /// 'static`) and the recovered clone drives the *same* queue.
546    /// Deliberately does not build a [`RouterDeepLinks`], so it never touches
547    /// the process-wide deep-link slot the test above owns.
548    #[test]
549    fn route_navigator_round_trips_through_context() {
550        use frust_reactive::{provide_context, use_context};
551
552        let rt = ReactiveRuntime::init(Arc::new(|| {}));
553        let router = router_with_error_leaf();
554        let nav = router.route_navigator();
555
556        let recovered = rt.with_owner(|| {
557            provide_context(nav.clone());
558            use_context::<RouteNavigator>()
559        });
560        let recovered = recovered.expect("a RouteNavigator must survive provide_context");
561
562        // Same queue, not a detached copy: what the recovered handle asks for is
563        // what the router pumps.
564        recovered.push("/settings");
565        router.pump();
566        assert_eq!(
567            nav.location().map(|loc| loc.path),
568            Some("/settings".to_string()),
569            "the context-recovered handle drove the original router"
570        );
571    }
572
573    // --- `normalize_deep_link`: one rule
574    // branch per test, mirroring `path.rs`'s test-heavy style for its own
575    // hand-rolled parser.
576
577    #[test]
578    fn normalize_bare_path_passes_through() {
579        // Rule 1: no `://` at all — pass through unchanged, including
580        // already-normalized paths and query strings.
581        assert_eq!(normalize_deep_link("/item/7"), "/item/7");
582        assert_eq!(normalize_deep_link("/"), "/");
583        assert_eq!(normalize_deep_link("/search?q=asdf"), "/search?q=asdf");
584    }
585
586    #[test]
587    fn normalize_custom_scheme_host_and_path() {
588        // Rule 2, custom scheme: host + path segments both map into the
589        // result path.
590        assert_eq!(normalize_deep_link("fktest://item/7"), "/item/7");
591        assert_eq!(
592            normalize_deep_link("fktest://item/7/nested"),
593            "/item/7/nested"
594        );
595    }
596
597    #[test]
598    fn normalize_custom_scheme_host_only() {
599        // Custom scheme with a host and no further path segments.
600        assert_eq!(normalize_deep_link("fktest://settings"), "/settings");
601    }
602
603    #[test]
604    fn normalize_custom_scheme_hostless() {
605        // Rule 2, custom scheme, hostless (`scheme:///path`): the empty host
606        // before the triple slash contributes nothing.
607        assert_eq!(normalize_deep_link("fktest:///settings"), "/settings");
608    }
609
610    #[test]
611    fn normalize_https_universal_link_drops_host() {
612        // Rule 2, http(s): the host is dropped entirely, keeping only the
613        // path onward.
614        assert_eq!(normalize_deep_link("https://host/item/7"), "/item/7");
615        assert_eq!(normalize_deep_link("http://host/item/7"), "/item/7");
616        // Scheme match is case-insensitive.
617        assert_eq!(normalize_deep_link("HTTPS://host/item/7"), "/item/7");
618    }
619
620    #[test]
621    fn normalize_trailing_and_empty_edge_cases() {
622        // https with a trailing slash and no further segments -> root.
623        assert_eq!(normalize_deep_link("https://host/"), "/");
624        // https with no path at all -> root.
625        assert_eq!(normalize_deep_link("https://host"), "/");
626        // Custom scheme with a trailing slash after the host -> host only.
627        assert_eq!(normalize_deep_link("fktest://item/"), "/item");
628        // Custom scheme with nothing after the scheme delimiter at all ->
629        // root, gracefully (no panic on unmappable input).
630        assert_eq!(normalize_deep_link("fktest://"), "/");
631        // Empty string has no `://` -> Rule 1 passthrough, unchanged.
632        assert_eq!(normalize_deep_link(""), "");
633    }
634}