Skip to main content

tpt_appfront_core/
router.rs

1//! Backend-agnostic client-side router.
2//!
3//! A real hash/history-based router is more than the bare [`route_signal`]
4//! pointer in [`crate::agent`] (which exists for AI-agent/devtools purposes).
5//! This module adds a route *table* with path-matching and parameter
6//! extraction, plus a [`Router`] that owns the current location, resolves it
7//! to a view, and notifies subscribers on navigation.
8//!
9//! The router is intentionally generic over the app `Msg` type and holds no
10//! browser-only APIs — backends wire real navigation (History API, hashchange)
11//! to [`Router::navigate`]. `appfront-dom` does this over the History API on
12//! `wasm32`; `appfront-html` / `appfront-ai-schema` resolve routes at
13//! crawl/generation time.
14
15use crate::signal::Signal;
16use crate::ui_tree::UITree;
17use std::collections::HashMap;
18use std::rc::Rc;
19
20/// A view-producing handler for a matched route. Receives the captured path
21/// params and returns the [`UITree`] for that route.
22pub type RouteHandler<Msg> = Rc<dyn Fn(&HashMap<String, String>) -> UITree<Msg>>;
23
24/// A compiled route pattern.
25///
26/// Patterns use `:name` segments to capture a single path component into the
27/// params map (e.g. `/users/:id` matches `/users/42` with `id = "42"`). A
28/// trailing `*` wildcard is not supported in v1.
29#[derive(Debug, Clone)]
30pub struct Route {
31    raw: String,
32    segments: Vec<Segment>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36enum Segment {
37    /// A literal path component that must match exactly.
38    Literal(String),
39    /// A `:name` capture; the matched component is stored under `name`.
40    Param(String),
41}
42
43impl Route {
44    /// Parses a route pattern. Returns `Err` if a segment is empty or a
45    /// `:param` has no name.
46    pub fn parse(pattern: &str) -> Result<Route, String> {
47        let trimmed = pattern.trim_matches('/');
48        let segments = if trimmed.is_empty() {
49            Vec::new()
50        } else {
51            trimmed
52                .split('/')
53                .map(|seg| {
54                    if seg.is_empty() {
55                        return Err(format!("empty segment in route `{pattern}`"));
56                    }
57                    if let Some(name) = seg.strip_prefix(':') {
58                        if name.is_empty() {
59                            return Err(format!("unnamed `:` param in route `{pattern}`"));
60                        }
61                        Ok(Segment::Param(name.to_string()))
62                    } else {
63                        Ok(Segment::Literal(seg.to_string()))
64                    }
65                })
66                .collect::<Result<Vec<_>, String>>()?
67        };
68        Ok(Route {
69            raw: pattern.to_string(),
70            segments,
71        })
72    }
73
74    /// The original pattern string this route was parsed from.
75    pub fn pattern(&self) -> &str {
76        &self.raw
77    }
78
79    /// Attempts to match `path` (a `/`-prefixed path), returning the captured
80    /// params on success.
81    pub fn match_path(&self, path: &str) -> Option<HashMap<String, String>> {
82        let trimmed = path.trim_matches('/');
83        let incoming: Vec<&str> = if trimmed.is_empty() {
84            Vec::new()
85        } else {
86            trimmed.split('/').collect()
87        };
88
89        // The root route (`/`) matches only the empty path.
90        if self.segments.is_empty() {
91            return if incoming.is_empty() {
92                Some(HashMap::new())
93            } else {
94                None
95            };
96        }
97
98        if incoming.len() != self.segments.len() {
99            return None;
100        }
101        let mut params = HashMap::new();
102        for (seg, raw) in self.segments.iter().zip(incoming.iter()) {
103            match seg {
104                Segment::Literal(lit) => {
105                    if lit != raw {
106                        return None;
107                    }
108                }
109                Segment::Param(name) => {
110                    params.insert(name.clone(), (*raw).to_string());
111                }
112            }
113        }
114        Some(params)
115    }
116}
117
118/// A route table mapping patterns to view-producing handlers.
119///
120/// Handlers receive the matched path params and return the [`UITree`] for that
121/// route. The first registered route wins on ambiguity, so register more
122/// specific routes before catch-alls.
123pub struct RouteTable<Msg> {
124    routes: Vec<(Route, RouteHandler<Msg>)>,
125    /// View returned when no route matches.
126    not_found: Option<Rc<dyn Fn() -> UITree<Msg>>>,
127}
128
129impl<Msg> Default for RouteTable<Msg> {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135impl<Msg> RouteTable<Msg> {
136    /// Creates an empty table.
137    pub fn new() -> Self {
138        RouteTable {
139            routes: Vec::new(),
140            not_found: None,
141        }
142    }
143
144    /// Registers a route. `pattern` uses `:param` captures; `handler` produces
145    /// the view for a matched route given its params.
146    pub fn route<F>(mut self, pattern: &str, handler: F) -> Result<Self, String>
147    where
148        F: Fn(&HashMap<String, String>) -> UITree<Msg> + 'static,
149    {
150        let route = Route::parse(pattern)?;
151        self.routes.push((route, Rc::new(handler)));
152        Ok(self)
153    }
154
155    /// Sets the not-found view used when no route matches.
156    pub fn fallback<F>(mut self, handler: F) -> Self
157    where
158        F: Fn() -> UITree<Msg> + 'static,
159    {
160        self.not_found = Some(Rc::new(handler));
161        self
162    }
163
164    /// Resolves `path` to a view, or the not-found view if nothing matches.
165    pub fn resolve(&self, path: &str) -> UITree<Msg> {
166        for (route, handler) in &self.routes {
167            if let Some(params) = route.match_path(path) {
168                return handler(&params);
169            }
170        }
171        if let Some(fb) = &self.not_found {
172            return fb();
173        }
174        // Last-resort empty container so callers always get a renderable tree.
175        UITree::container(|_| {})
176    }
177}
178
179/// A reactive router that owns the current location and re-resolves the route
180/// table on navigation.
181///
182/// `Router` is cheap to clone (internally `Rc`-backed) and integrates with the
183/// existing reactive [`Signal`] so effects subscribed to [`Router::signal`]
184/// re-run on every navigation — that is how a backend rebuilds the UI tree.
185pub struct Router<Msg> {
186    inner: Rc<RouterInner<Msg>>,
187}
188
189struct RouterInner<Msg> {
190    table: RouteTable<Msg>,
191    location: Signal<String>,
192}
193
194impl<Msg> Clone for Router<Msg> {
195    fn clone(&self) -> Self {
196        Router {
197            inner: self.inner.clone(),
198        }
199    }
200}
201
202impl<Msg> Router<Msg> {
203    /// Builds a router from a [`RouteTable`], starting at `initial_path`.
204    pub fn new(table: RouteTable<Msg>, initial_path: &str) -> Self {
205        Router {
206            inner: Rc::new(RouterInner {
207                table,
208                location: Signal::new(initial_path.to_string()),
209            }),
210        }
211    }
212
213    /// The current path.
214    pub fn current_path(&self) -> String {
215        self.inner.location.get()
216    }
217
218    /// Navigates to `path`, updating the location signal (which re-triggers any
219    /// subscribed effects). A backend typically also syncs the browser URL.
220    pub fn navigate(&self, path: &str) {
221        self.inner.location.set(path.to_string());
222    }
223
224    /// Returns the location signal so effects can subscribe to navigation.
225    pub fn signal(&self) -> Signal<String> {
226        self.inner.location.clone()
227    }
228
229    /// Resolves the current path to a view.
230    pub fn current_view(&self) -> UITree<Msg> {
231        self.inner.table.resolve(&self.inner.location.get())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::ui_tree::{ContainerBuilder, NodeKind};
239
240    #[test]
241    fn route_match_literal_and_root() {
242        let r = Route::parse("/").unwrap();
243        assert!(r.match_path("/").is_some());
244        assert!(r.match_path("/x").is_none());
245
246        let r = Route::parse("/about").unwrap();
247        assert!(r.match_path("/about").is_some());
248        assert!(r.match_path("/").is_none());
249        assert!(r.match_path("/about/extra").is_none());
250    }
251
252    #[test]
253    fn route_match_param_capture() {
254        let r = Route::parse("/users/:id").unwrap();
255        let m = r.match_path("/users/42").unwrap();
256        assert_eq!(m.get("id"), Some(&"42".to_string()));
257        assert!(r.match_path("/users").is_none());
258        assert!(r.match_path("/posts/42").is_none());
259    }
260
261    #[test]
262    fn route_table_resolves_and_falls_back() {
263        let table = RouteTable::<()>::new()
264            .route("/", |_| UITree::container(|_| {}))
265            .unwrap()
266            .route("/users/:id", |p| {
267                let _ = p;
268                UITree::container(|b: &mut ContainerBuilder<()>| {
269                    b.text(format!("user {}", p.get("id").unwrap()));
270                })
271            })
272            .unwrap()
273            .fallback(|| UITree::container(|_| {}));
274
275        assert!(matches!(table.resolve("/"), UITree { kind: NodeKind::Container { .. }, .. }));
276        let view = table.resolve("/users/7");
277        assert!(matches!(view, UITree { kind: NodeKind::Container { .. }, .. }));
278        // Unknown route hits the fallback.
279        assert!(matches!(table.resolve("/nope"), UITree { kind: NodeKind::Container { .. }, .. }));
280    }
281
282    #[test]
283    fn router_navigation_updates_view_via_signal() {
284        let table = RouteTable::<()>::new()
285            .route("/", |_| UITree::container(|_| {}))
286            .unwrap()
287            .route("/b", |_| UITree::container(|_| {}))
288            .unwrap();
289        let router = Router::new(table, "/");
290        assert_eq!(router.current_path(), "/");
291
292        let sig = router.signal();
293        let before = sig.get();
294        router.navigate("/b");
295        assert_eq!(sig.get(), "/b");
296        assert_ne!(before, sig.get());
297    }
298
299    #[test]
300    fn route_parse_rejects_bad_patterns() {
301        // A bare `:` with no name is rejected.
302        assert!(Route::parse("/:").is_err());
303        // Trailing slashes are normalised away, so `/users/` == `/users`.
304        assert!(Route::parse("/users/").is_ok());
305    }
306}