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 `app_logic` 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}