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, any, navigator, text};
257///
258/// #[derive(Default)]
259/// struct App;
260///
261/// struct AppState {
262///     router_links: RouterDeepLinks<AppState>,
263/// }
264///
265/// impl Component for App {
266///     type State = AppState;
267///
268///     fn init(&self) -> AppState {
269///         let router: Router<AppState> =
270///             Router::new(vec![Route::new("/", |_params| -> AnyView<AppState> {
271///                 any(text("home"))
272///             })]);
273///         // Resolves the start location once: a cold-start deep link wins
274///         // over "/" if one already arrived.
275///         AppState {
276///             router_links: frust::router_with_deep_links(router, "/"),
277///         }
278///     }
279///
280///     fn build(&self, state: &mut AppState) -> AnyView<AppState> {
281///         // Called every rebuild: navigates on a new warm link, no-ops
282///         // otherwise (see the module docs' dedupe contract).
283///         state.router_links.track();
284///         let controller = state.router_links.router().controller();
285///         any(navigator(controller, || any(text("home"))))
286///     }
287/// }
288///
289/// frust::app!(App);
290/// # fn main() {}
291/// ```
292pub fn router_with_deep_links<State: 'static>(
293    router: Router<State>,
294    initial_location: &str,
295) -> RouterDeepLinks<State> {
296    RouterDeepLinks::new(router, initial_location)
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use frust_core::{AnyView, FrameTime, PaintScene, RenderRoot, View, any};
303    use frust_reactive::{ReactiveRuntime, push_deep_link};
304    use frust_widgets::{NavigatorView, Route, navigator};
305    use kurbo::{Point, Size};
306    use std::sync::Arc;
307
308    // --- A GPU/text-free sized leaf, mirroring `router.rs`'s own test
309    // fixtures, so a route's page can be distinguished by its painted size
310    // without touching the `Text` widget (which panics without a threaded
311    // `TextContext` — see `frust_core::widget::LayoutCtx::text_context`).
312
313    struct SizedLeaf {
314        size: Size,
315    }
316    struct SizedLeafWidget {
317        size: Size,
318    }
319    impl View<()> for SizedLeaf {
320        type Element = SizedLeafWidget;
321        fn build(&self, _ctx: &mut frust_core::BuildCtx<'_>) -> SizedLeafWidget {
322            SizedLeafWidget { size: self.size }
323        }
324        fn rebuild(
325            &self,
326            _prev: &Self,
327            element: &mut SizedLeafWidget,
328            _ctx: &mut frust_core::BuildCtx<'_>,
329        ) -> frust_core::ChangeFlags {
330            element.size = self.size;
331            frust_core::ChangeFlags::LAYOUT
332        }
333    }
334    impl frust_core::Widget for SizedLeafWidget {
335        fn layout(
336            &mut self,
337            _ctx: &mut frust_core::LayoutCtx,
338            bc: &frust_core::BoxConstraints,
339        ) -> Size {
340            bc.constrain(self.size)
341        }
342        fn paint(&mut self, ctx: &mut frust_core::PaintCtx, scene: &mut dyn PaintScene) {
343            scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
344        }
345    }
346    fn sized(w: f64, h: f64) -> AnyView<()> {
347        any(SizedLeaf {
348            size: Size::new(w, h),
349        })
350    }
351
352    #[derive(Default)]
353    struct RecordingScene {
354        rects: Vec<(Point, Size)>,
355    }
356    impl PaintScene for RecordingScene {
357        fn fill_rect(&mut self, origin: Point, size: Size, _color: peniko::Color) {
358            self.rects.push((origin, size));
359        }
360        fn draw_text(&mut self, _origin: Point, _text: &str) {}
361    }
362
363    const HOME: Size = Size::new(10.0, 10.0);
364    const PROFILE: Size = Size::new(20.0, 20.0);
365    const SETTINGS: Size = Size::new(30.0, 30.0);
366    const ERROR: Size = Size::new(99.0, 99.0);
367
368    fn routes() -> Vec<Route<()>> {
369        vec![
370            Route::new("/", |_params| sized(HOME.width, HOME.height)),
371            Route::new("/profile/:id", |_params| {
372                sized(PROFILE.width, PROFILE.height)
373            }),
374            Route::new("/settings", |_params| {
375                sized(SETTINGS.width, SETTINGS.height)
376            }),
377        ]
378    }
379
380    fn router_with_error_leaf() -> Router<()> {
381        Router::new(routes()).error_builder(|_loc| sized(ERROR.width, ERROR.height))
382    }
383
384    /// The build closure type [`RenderRoot::rebuild`] drives, boxed so
385    /// [`Harness`] can store it as a field.
386    type AppLogic = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
387
388    /// A `RouterDeepLinks` plus the `RenderRoot`/`app` closure driving its
389    /// controller's `navigator`, all built once and reused across a test's
390    /// assertions — mirrors `router.rs`'s own tests: a fresh `RenderRoot` per
391    /// assertion would lose the controller's already-drained op history.
392    struct Harness {
393        links: RouterDeepLinks<()>,
394        root: RenderRoot<(), NavigatorView<()>>,
395        app: AppLogic,
396        state: (),
397    }
398
399    impl Harness {
400        fn new(router: Router<()>, initial_location: &str) -> Self {
401            let links = RouterDeepLinks::new(router, initial_location);
402            let controller = links.router().controller().clone();
403            let app: AppLogic = Box::new(move |_: &mut ()| {
404                navigator(&controller, || sized(HOME.width, HOME.height))
405            });
406            let mut harness = Harness {
407                links,
408                root: RenderRoot::new(),
409                app,
410                state: (),
411            };
412            harness.rebuild();
413            harness
414        }
415
416        fn rebuild(&mut self) {
417            self.root.rebuild(&mut self.app, &mut self.state);
418        }
419
420        /// Track the deep-link signal, rebuild, and return the size of the
421        /// page currently painted on top of the stack.
422        fn track_and_paint(&mut self) -> Size {
423            self.links.track();
424            self.rebuild();
425            self.paint()
426        }
427
428        fn paint(&mut self) -> Size {
429            self.root.layout(Size::new(100.0, 100.0));
430            let mut scene = RecordingScene::default();
431            self.root.paint(&mut scene, FrameTime::ZERO);
432            scene
433                .rects
434                .last()
435                .map(|(_, size)| *size)
436                .unwrap_or(Size::ZERO)
437        }
438    }
439
440    // All four acceptance criteria live in ONE `#[test]` function,
441    // deliberately: `frust_reactive::deep_link`'s process-wide `SLOT` (in
442    // particular `DeepLinks::initial`, "the first link ever pushed in this
443    // process" — see its module docs) is shared by every test in this binary,
444    // and this is the only test in `frust`'s suite that touches it. A
445    // single function gives deterministic ordering (no cross-test race on
446    // that global) without needing a crate-private test lock like
447    // `frust_reactive::deep_link`'s own tests use (`WAKER_TEST_LOCK` is
448    // `pub(crate)`, unreachable from here) — and this test asserts nothing
449    // about waker/wake-count, the one thing that lock actually protects.
450    #[test]
451    fn router_deep_link_glue() {
452        let _rt = ReactiveRuntime::init(Arc::new(|| {}));
453
454        // Criterion 1a: no link queued yet anywhere in this process — a fresh
455        // router starts at `initial_location`, not any stale state.
456        let mut cold_no_link = Harness::new(router_with_error_leaf(), "/");
457        assert_eq!(cold_no_link.paint(), HOME);
458
459        // Criterion 1b: a deep link queued before the router is constructed
460        // wins over `initial_location` — this is also, deliberately, the
461        // FIRST push in this test binary, so it becomes `DeepLinks::initial`
462        // for the rest of this test (and the process).
463        push_deep_link("/profile/42");
464        let mut warm = Harness::new(router_with_error_leaf(), "/");
465        assert_eq!(warm.paint(), PROFILE);
466
467        // Criterion 5a (cold-start dedupe, the regression this task fixes):
468        // the constructor seeds the consumed marker from the SEQUENCE of the
469        // real cold-start delivery (read from `latest`), not a freshly minted
470        // one — so navigate away by hand, then call `track()` with no new
471        // push, and the router must stay put rather than re-navigating back
472        // to `/profile/42`. Against the pre-fix code (which minted a new
473        // `DeepLink` — and therefore a new sequence — to seed the marker)
474        // this assertion fails: the freshly minted sequence never equals the
475        // real delivery's, so `track()` treats the cold-start link as
476        // unconsumed and calls `handle_location` again.
477        warm.links.router().go("/");
478        warm.rebuild();
479        assert_eq!(warm.paint(), HOME);
480        assert_eq!(
481            warm.track_and_paint(),
482            HOME,
483            "track() must not re-navigate to the cold-start link on the first call"
484        );
485
486        // Criterion 5b (repeat delivery): pushing the identical URL again is
487        // a genuinely new delivery (a new sequence), so it must navigate
488        // again rather than being treated as already consumed.
489        push_deep_link("/profile/42");
490        assert_eq!(
491            warm.track_and_paint(),
492            PROFILE,
493            "an identical URL delivered again must still navigate"
494        );
495        warm.links.router().go("/");
496        warm.rebuild();
497        assert_eq!(warm.paint(), HOME);
498
499        // Criterion 2: a warm link pushed during a running session navigates
500        // on the next `track()`.
501        push_deep_link("/settings");
502        assert_eq!(warm.track_and_paint(), SETTINGS);
503
504        // Criterion 2 (dedupe): manually navigate elsewhere, then call
505        // `track()` again with no new push — the same link must not be
506        // re-handled (it would jump back to SETTINGS if it were).
507        warm.links.router().go("/");
508        warm.rebuild();
509        assert_eq!(warm.paint(), HOME);
510        assert_eq!(
511            warm.track_and_paint(),
512            HOME,
513            "track() must not re-navigate to an already-consumed link"
514        );
515
516        // Criterion 3: a malformed/unmatched link hits the router's error
517        // page — no panic.
518        push_deep_link("/does/not/exist");
519        assert_eq!(warm.track_and_paint(), ERROR);
520
521        // Criterion 4 (the `RouteNavigator` pump): a request queued the way a
522        // screen's event handler would — through the context-safe handle, with
523        // no router in sight — is applied by the very next `track()`, with no
524        // new deep link involved. Asserted inside this same function for the
525        // same process-wide-slot reason as the criteria above: `track()` reads
526        // `latest`, whose value is only known here.
527        warm.links.route_navigator().go("/settings");
528        assert_eq!(
529            warm.track_and_paint(),
530            SETTINGS,
531            "track() must pump the RouteNavigator queue"
532        );
533
534        // ...and pumping an empty queue on every subsequent build is inert.
535        warm.links.router().go("/");
536        warm.rebuild();
537        assert_eq!(warm.track_and_paint(), HOME);
538    }
539
540    /// The context round-trip contract: a [`RouteNavigator`] round-trips
541    /// through `provide_context`/`use_context` (which require `Send + Sync +
542    /// 'static`) and the recovered clone drives the *same* queue.
543    /// Deliberately does not build a [`RouterDeepLinks`], so it never touches
544    /// the process-wide deep-link slot the test above owns.
545    #[test]
546    fn route_navigator_round_trips_through_context() {
547        use frust_reactive::{provide_context, use_context};
548
549        let rt = ReactiveRuntime::init(Arc::new(|| {}));
550        let router = router_with_error_leaf();
551        let nav = router.route_navigator();
552
553        let recovered = rt.with_owner(|| {
554            provide_context(nav.clone());
555            use_context::<RouteNavigator>()
556        });
557        let recovered = recovered.expect("a RouteNavigator must survive provide_context");
558
559        // Same queue, not a detached copy: what the recovered handle asks for is
560        // what the router pumps.
561        recovered.push("/settings");
562        router.pump();
563        assert_eq!(
564            nav.location().map(|loc| loc.path),
565            Some("/settings".to_string()),
566            "the context-recovered handle drove the original router"
567        );
568    }
569
570    // --- `normalize_deep_link`: one rule
571    // branch per test, mirroring `path.rs`'s test-heavy style for its own
572    // hand-rolled parser.
573
574    #[test]
575    fn normalize_bare_path_passes_through() {
576        // Rule 1: no `://` at all — pass through unchanged, including
577        // already-normalized paths and query strings.
578        assert_eq!(normalize_deep_link("/item/7"), "/item/7");
579        assert_eq!(normalize_deep_link("/"), "/");
580        assert_eq!(normalize_deep_link("/search?q=asdf"), "/search?q=asdf");
581    }
582
583    #[test]
584    fn normalize_custom_scheme_host_and_path() {
585        // Rule 2, custom scheme: host + path segments both map into the
586        // result path.
587        assert_eq!(normalize_deep_link("fktest://item/7"), "/item/7");
588        assert_eq!(
589            normalize_deep_link("fktest://item/7/nested"),
590            "/item/7/nested"
591        );
592    }
593
594    #[test]
595    fn normalize_custom_scheme_host_only() {
596        // Custom scheme with a host and no further path segments.
597        assert_eq!(normalize_deep_link("fktest://settings"), "/settings");
598    }
599
600    #[test]
601    fn normalize_custom_scheme_hostless() {
602        // Rule 2, custom scheme, hostless (`scheme:///path`): the empty host
603        // before the triple slash contributes nothing.
604        assert_eq!(normalize_deep_link("fktest:///settings"), "/settings");
605    }
606
607    #[test]
608    fn normalize_https_universal_link_drops_host() {
609        // Rule 2, http(s): the host is dropped entirely, keeping only the
610        // path onward.
611        assert_eq!(normalize_deep_link("https://host/item/7"), "/item/7");
612        assert_eq!(normalize_deep_link("http://host/item/7"), "/item/7");
613        // Scheme match is case-insensitive.
614        assert_eq!(normalize_deep_link("HTTPS://host/item/7"), "/item/7");
615    }
616
617    #[test]
618    fn normalize_trailing_and_empty_edge_cases() {
619        // https with a trailing slash and no further segments -> root.
620        assert_eq!(normalize_deep_link("https://host/"), "/");
621        // https with no path at all -> root.
622        assert_eq!(normalize_deep_link("https://host"), "/");
623        // Custom scheme with a trailing slash after the host -> host only.
624        assert_eq!(normalize_deep_link("fktest://item/"), "/item");
625        // Custom scheme with nothing after the scheme delimiter at all ->
626        // root, gracefully (no panic on unmappable input).
627        assert_eq!(normalize_deep_link("fktest://"), "/");
628        // Empty string has no `://` -> Rule 1 passthrough, unchanged.
629        assert_eq!(normalize_deep_link(""), "");
630    }
631}