Skip to main content

max / quasi

16.1 KB · 437 lines History Blame Raw
1 //! Host-agnostic routing: a request in, a renderer-agnostic description out.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The keystone of the stack. A route answers with what a screen *is*, composed
6 //! from `makeover-layout`'s vocabulary, and the host decides how that becomes
7 //! pixels. Returning markup instead would pin every consumer to a webview and
8 //! hand the terminal and egui renderers an adapter, which is the failure the
9 //! description layer exists to prevent.
10 //!
11 //! Nothing here may import a host crate. `tauri`, `axum` and `wry` are all thin
12 //! adapters written on top of this, never dependencies of it. That rule is what
13 //! keeps a host repriceable per app instead of welded into the view layer, and
14 //! it is what lets the same route serve a desktop protocol handler and an HTTP
15 //! endpoint with no second implementation.
16 //!
17 //! # The shape
18 //!
19 //! ```text
20 //! request (path + params)
21 //! -> router this crate, imports no host crate
22 //! -> description makeover-layout, composed by this crate's Screen
23 //! -> renderer webview | tui | egui
24 //! -> host adapter axum route | Tauri protocol | wry | direct call
25 //! ```
26 //!
27 //! The renderers share no trait, and looking for one is the wrong move: what
28 //! they have in common is the description above them. `quasi_http::Serves` is
29 //! the nearest thing and is not it — that is an HTTP host's contract, which a
30 //! terminal cannot implement and does not need.
31 //!
32 //! ```
33 //! use quasi_router::{Action, Node, Outcome, Request, Response, RouteError, Router, Screen, Slot};
34 //! use quasi_router::layout::{Arrangement, Region};
35 //!
36 //! struct App {
37 //! tasks: Vec<(u32, String, bool)>,
38 //! }
39 //!
40 //! fn show_task(app: &App, request: Request) -> Result<Response, RouteError> {
41 //! let id: u32 = request
42 //! .captures
43 //! .require("id")?
44 //! .parse()
45 //! .map_err(|_| RouteError::not_found("no such task"))?;
46 //! let (_, title, done) = app
47 //! .tasks
48 //! .iter()
49 //! .find(|(t, _, _)| *t == id)
50 //! .ok_or_else(|| RouteError::not_found("no such task"))?;
51 //!
52 //! let mut detail = Slot::new("detail", quasi_router::RegionKind::Pane)
53 //! .with(Node::page(title.clone()));
54 //! if !done {
55 //! detail = detail.with(Node::act(
56 //! "Complete",
57 //! Action::post(format!("/task/{id}/complete")),
58 //! ));
59 //! }
60 //!
61 //! Ok(Screen::list_detail(title.clone(), false).with(detail).into())
62 //! }
63 //!
64 //! fn complete_task(_app: &App, request: Request) -> Result<Response, RouteError> {
65 //! let id = request.captures.require("id")?;
66 //! Ok(Response::fragment(
67 //! "detail",
68 //! Node::text(format!("task {id} is done")),
69 //! ))
70 //! }
71 //!
72 //! let router = Router::<App>::new()
73 //! .get("/task/{id}", show_task)
74 //! .post("/task/{id}/complete", complete_task);
75 //!
76 //! let app = App { tasks: vec![(7, "Write the router".into(), false)] };
77 //! let answer = router.handle(&app, Request::get("/task/7")).unwrap();
78 //! assert!(matches!(answer.outcome, Outcome::Screen(_)));
79 //! ```
80 //!
81 //! # What is settled, and where it is written down
82 //!
83 //! The design lives on the wiki note `quasi-overview`, and the decisions this
84 //! crate implements are numbered there. In short:
85 //!
86 //! - **An action is a route** (2). One address space for reads and writes, and
87 //! the verb separates them. See [`Method`].
88 //! - **The screen tree lives here, not in `makeover-layout`** (3). It will churn
89 //! while the router is proven against a second host, and a route is an address,
90 //! which is the one thing the description layer never names. See [`Screen`].
91 //! - **A bespoke region is filled per host** (4). See [`RegionKind::Bespoke`].
92 //! - **The router is sync** (6). See [`Handler`].
93 //! - **A response carries a target, and may carry a notice or a redirect** (7).
94 //! See [`Response`] and [`Outcome`].
95 //! - **`Router<S>`, generic over app state** (8). See [`Router`].
96 //! - **Failure is classified** (9). See [`RouteError`].
97
98 pub mod chrome;
99 pub mod containment;
100 pub mod error;
101 mod path;
102 pub mod request;
103 pub mod response;
104 pub mod router;
105 pub mod screen;
106
107 /// The description layer, re-exported.
108 ///
109 /// Every intent a [`Screen`] composes is `makeover-layout`'s, and a consumer
110 /// needs them to build one. Re-exported so that an app and a renderer are
111 /// provably reading the same version of the vocabulary rather than two
112 /// semver-compatible ones that happen to resolve together.
113 pub use makeover_layout as layout;
114
115 pub use crate::chrome::{Binding, Chrome};
116 pub use crate::containment::{Containment, Element, Level, Of};
117 pub use crate::error::{Class, RouteError};
118 pub use crate::request::{Method, Params, Request};
119 pub use crate::response::{Address, Invalidated, Message, Outcome, Response};
120 pub use crate::router::{Handler, Router};
121 pub use crate::screen::{
122 Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node,
123 Part, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag,
124 };
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129 use crate::layout::{Arrangement, Tone};
130
131 /// The smallest app state a route can be written against.
132 struct State {
133 greeting: &'static str,
134 }
135
136 fn home(state: &State, _request: Request) -> Result<Response, RouteError> {
137 Ok(Screen::sidebar_content("Home")
138 .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting)))
139 .into())
140 }
141
142 fn new_task(_state: &State, _request: Request) -> Result<Response, RouteError> {
143 Ok(Response::fragment("detail", Node::text("a new task")))
144 }
145
146 // Taken by value because [`Handler`] says so, and a handler that only reads
147 // its parameters is the common case rather than an oversight.
148 #[allow(clippy::needless_pass_by_value)]
149 fn show_task(_state: &State, request: Request) -> Result<Response, RouteError> {
150 let id = request.captures.require("id")?.to_owned();
151 Ok(Response::fragment("detail", Node::text(id)))
152 }
153
154 fn forbidden(_state: &State, _request: Request) -> Result<Response, RouteError> {
155 Err(RouteError::denied("not yours"))
156 }
157
158 fn router() -> Router<State> {
159 // Deliberately registered least-specific-first, so the ordering being
160 // tested is the table's own and not the order of these lines.
161 Router::new()
162 .get("/task/{id}", show_task)
163 .get("/task/new", new_task)
164 .get("/", home)
165 .post("/task/{id}/delete", forbidden)
166 }
167
168 fn state() -> State {
169 State { greeting: "hello" }
170 }
171
172 fn text_of(response: &Response) -> Option<&str> {
173 match &response.outcome {
174 Outcome::Fragment {
175 node: Node::Text { text, .. },
176 ..
177 } => Some(text),
178 _ => None,
179 }
180 }
181
182 #[test]
183 fn a_static_route_beats_a_capture_whatever_the_order() {
184 let answer = router()
185 .handle(&state(), Request::get("/task/new"))
186 .unwrap();
187 assert_eq!(text_of(&answer), Some("a new task"));
188 }
189
190 #[test]
191 fn a_capture_reaches_the_handler() {
192 let answer = router().handle(&state(), Request::get("/task/7")).unwrap();
193 assert_eq!(text_of(&answer), Some("7"));
194 }
195
196 #[test]
197 fn the_path_capture_wins_over_a_supplied_value() {
198 // The path is the address; the body is only what was sent to it.
199 let sent = Params::new().with("id", "9");
200 let answer = router()
201 .handle(&state(), Request::get("/task/7").carrying(sent))
202 .unwrap();
203 assert_eq!(text_of(&answer), Some("7"));
204 }
205
206 #[test]
207 fn a_read_and_a_write_are_different_routes_at_one_address() {
208 let router = router();
209 let missing = router
210 .handle(&state(), Request::post("/task/7"))
211 .unwrap_err();
212 assert_eq!(missing.class, Class::NotFound);
213 assert!(missing.message.contains("another method"));
214 }
215
216 #[test]
217 fn an_unknown_path_says_so_without_mentioning_a_method() {
218 let missing = router()
219 .handle(&state(), Request::get("/nowhere"))
220 .unwrap_err();
221 assert_eq!(missing.class, Class::NotFound);
222 assert!(!missing.message.contains("another method"));
223 }
224
225 #[test]
226 fn a_denial_carries_a_banner_and_a_status() {
227 let denied = router()
228 .handle(&state(), Request::post("/task/7/delete"))
229 .unwrap_err();
230 assert_eq!(denied.class, Class::Denied);
231 assert_eq!(denied.class.http_status(), 403);
232 assert_eq!(denied.notice, layout::Notice::Banner);
233 assert_eq!(denied.tone(), Tone::Warning);
234 assert!(!denied.class.is_ours());
235 }
236
237 #[test]
238 fn a_missing_parameter_is_our_bug_not_the_users() {
239 // Reached only by calling the handler outside the router, which is what
240 // a renderer emitting an unfilled route amounts to.
241 let missing = show_task(&state(), Request::get("/task/7")).unwrap_err();
242 assert_eq!(missing.class, Class::Internal);
243 assert!(missing.class.is_ours());
244 }
245
246 #[test]
247 fn a_screen_answers_with_no_target_and_a_fragment_with_one() {
248 let router = router();
249 let screen = router.handle(&state(), Request::get("/")).unwrap();
250 assert_eq!(screen.target(), None);
251
252 let fragment = router.handle(&state(), Request::get("/task/7")).unwrap();
253 assert_eq!(fragment.target(), Some("detail"));
254 }
255
256 #[test]
257 fn the_route_table_reads_back_most_specific_first() {
258 let router = router();
259 let table: Vec<_> = router.routes().collect();
260 let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap();
261 let id_at = table.iter().position(|(_, p)| *p == "/task/{id}").unwrap();
262 assert!(new_at < id_at);
263 assert_eq!(router.len(), 4);
264 }
265
266 #[test]
267 #[should_panic(expected = "registered twice")]
268 fn registering_one_route_twice_is_a_bug() {
269 let _ = Router::<State>::new()
270 .get("/task/{id}", show_task)
271 .get("/task/{id}", show_task);
272 }
273
274 #[test]
275 fn a_slot_is_found_at_any_depth() {
276 let screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
277 Slot::new("tabs", RegionKind::TabGroup)
278 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
279 );
280 assert!(screen.slot("tabs").is_some());
281 assert!(screen.slot("pane-a").is_some());
282 assert!(screen.slot("pane-b").is_none());
283 }
284
285 #[test]
286 fn a_fragment_replaces_a_top_level_regions_contents() {
287 let mut screen = Screen::sidebar_content("Home").with(
288 Slot::new("content", RegionKind::Pane)
289 .with(Node::text("first"))
290 .with(Node::text("second")),
291 );
292
293 assert!(screen.replace("content", Node::text("after")));
294
295 let slot = screen.slot("content").expect("the region is still there");
296 // Replaced, not appended: a fragment is one region's new contents,
297 // which is the whole reason it can be smaller than a screen.
298 assert_eq!(slot.body, vec![Node::text("after")]);
299 }
300
301 #[test]
302 fn a_fragment_reaches_a_region_nested_inside_another() {
303 let mut screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
304 Slot::new("tabs", RegionKind::TabGroup)
305 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
306 );
307
308 assert!(screen.replace("pane-a", Node::text("loaded")));
309
310 let pane = screen.slot("pane-a").expect("the nested region");
311 assert_eq!(pane.body, vec![Node::text("loaded")]);
312 // The region it is inside keeps its own body, which still holds the
313 // nested region rather than having been replaced by it.
314 let tabs = screen.slot("tabs").expect("the outer region");
315 assert_eq!(tabs.body.len(), 1);
316 }
317
318 #[test]
319 fn a_pending_region_stops_being_pending_when_its_content_arrives() {
320 let mut screen =
321 Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane).pending());
322
323 assert!(screen.replace("content", Node::text("here")));
324
325 let slot = screen.slot("content").expect("the region");
326 assert_eq!(slot.readiness, layout::Readiness::Ready);
327 }
328
329 #[test]
330 fn a_fragment_for_a_region_that_is_not_there_answers_false() {
331 let mut screen =
332 Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
333
334 // Not a panic and not a silent no-op: a miss means a route naming a
335 // slot that no longer exists, so the caller is the one that can act on
336 // it -- redraw, or fail a test.
337 assert!(!screen.replace("detail", Node::text("nowhere")));
338 assert!(screen.slot("content").expect("untouched").body.is_empty());
339 }
340
341 #[test]
342 fn an_owned_field_borrows_back_as_the_description_layers_own() {
343 let field = Field::select(
344 "priority",
345 "Priority",
346 vec![Choice::plain("high"), Choice::new("low", "Low")],
347 )
348 .required()
349 .error("pick one");
350
351 field.with_layout(|borrowed| {
352 assert_eq!(borrowed.name, "priority");
353 assert_eq!(borrowed.options.len(), 2);
354 assert_eq!(borrowed.options[1].label, "Low");
355 assert!(borrowed.required);
356 // The description layer's own reading of the same state.
357 assert!(borrowed.invalid());
358 assert!(borrowed.kind.offers_options());
359 });
360 }
361
362 #[test]
363 fn a_bespoke_region_is_the_only_undescribed_one() {
364 assert!(RegionKind::Pane.described());
365 assert!(
366 !RegionKind::Bespoke {
367 name: "day-plan".into()
368 }
369 .described()
370 );
371 }
372
373 #[test]
374 fn a_screen_nobody_described_is_indexable() {
375 // `bool::default()` is false, so a derived Default here would deindex
376 // every screen that never mentioned the subject. The impl is written
377 // out for exactly this, and this is the assertion that keeps it.
378 assert!(Discovery::default().indexable);
379 assert!(Screen::sidebar_content("Home").discovery.indexable);
380 assert!(
381 !Screen::sidebar_content("Home")
382 .indexed(false)
383 .discovery
384 .indexable
385 );
386 }
387
388 #[test]
389 fn every_social_kind_has_a_distinct_spelling() {
390 // A kind added upstream without a spelling fails here rather than
391 // reaching a crawler as an og:type nothing recognises.
392 let kinds = [
393 SocialKind::Website,
394 SocialKind::Article,
395 SocialKind::Profile,
396 SocialKind::Product,
397 SocialKind::Video,
398 SocialKind::Song,
399 ];
400 let mut seen = Vec::new();
401 for kind in kinds {
402 let spelling = kind.as_str();
403 assert!(!spelling.is_empty(), "{kind:?} spells nothing");
404 assert!(!seen.contains(&spelling), "{spelling} is spelled twice");
405 seen.push(spelling);
406 }
407 assert_eq!(SocialKind::default(), SocialKind::Website);
408 }
409
410 #[test]
411 fn an_overlay_targets_no_region_and_sends_the_user_nowhere() {
412 // The two questions a host asks before it looks for a body. An overlay
413 // answers no to both: it is not aimed at a region, and dismissing it
414 // reveals the screen the user never left.
415 let answer = Response::over(Screen::sidebar_content("Palette"));
416 assert!(answer.target().is_none());
417 assert!(answer.destination().is_none());
418 assert!(matches!(answer.outcome, Outcome::Over(_)));
419 }
420
421 #[test]
422 fn chrome_is_held_beside_the_router_rather_than_arriving_with_an_answer() {
423 // The claim `Chrome` makes, as a type: it is built once and no
424 // `Response` carries one, so an affordance available everywhere cannot
425 // be a fact about one answer.
426 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
427 assert_eq!(
428 chrome.bound("ctrl+k").map(|b| b.label.as_str()),
429 Some("Search")
430 );
431 assert_eq!(
432 chrome.bound("ctrl+k").map(|b| &b.action),
433 Some(&Action::get("/palette"))
434 );
435 }
436 }
437