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}