//! Host-agnostic routing: a request in, a renderer-agnostic description out. //! //! //! //! The keystone of the stack. A route answers with what a screen *is*, composed //! from `makeover-layout`'s vocabulary, and the host decides how that becomes //! pixels. Returning markup instead would pin every consumer to a webview and //! hand the terminal and egui renderers an adapter, which is the failure the //! description layer exists to prevent. //! //! Nothing here may import a host crate. `tauri`, `axum` and `wry` are all thin //! adapters written on top of this, never dependencies of it. That rule is what //! keeps a host repriceable per app instead of welded into the view layer, and //! it is what lets the same route serve a desktop protocol handler and an HTTP //! endpoint with no second implementation. //! //! # The shape //! //! ```text //! request (path + params) //! -> router this crate, imports no host crate //! -> description makeover-layout, composed by this crate's Screen //! -> renderer webview | tui | egui //! -> host adapter axum route | Tauri protocol | wry | direct call //! ``` //! //! The renderers share no trait, and looking for one is the wrong move: what //! they have in common is the description above them. `quasi_http::Serves` is //! the nearest thing and is not it — that is an HTTP host's contract, which a //! terminal cannot implement and does not need. //! //! [`Renderer`] is not that trait either, and is not a step towards one. It is //! a two-variant classification answering the single question a description //! cannot ask: whether this renderer has to be told what happens without a //! request. See [`Destination::Local`]. //! //! ``` //! use quasi_router::{Action, Node, Outcome, Request, Response, RouteError, Router, Screen, Slot}; //! use quasi_router::layout::{Arrangement, Region}; //! //! struct App { //! tasks: Vec<(u32, String, bool)>, //! } //! //! fn show_task(app: &App, request: Request) -> Result { //! let id: u32 = request //! .captures //! .require("id")? //! .parse() //! .map_err(|_| RouteError::not_found("no such task"))?; //! let (_, title, done) = app //! .tasks //! .iter() //! .find(|(t, _, _)| *t == id) //! .ok_or_else(|| RouteError::not_found("no such task"))?; //! //! let mut detail = Slot::new("detail", quasi_router::RegionKind::Pane) //! .with(Node::page(title.clone())); //! if !done { //! detail = detail.with(Node::act( //! "Complete", //! Action::post(format!("/task/{id}/complete")), //! )); //! } //! //! Ok(Screen::list_detail(title.clone(), false).with(detail).into()) //! } //! //! fn complete_task(_app: &App, request: Request) -> Result { //! let id = request.captures.require("id")?; //! Ok(Response::fragment( //! "detail", //! Node::text(format!("task {id} is done")), //! )) //! } //! //! let router = Router::::new() //! .get("/task/{id}", show_task) //! .post("/task/{id}/complete", complete_task); //! //! let app = App { tasks: vec![(7, "Write the router".into(), false)] }; //! let answer = router.handle(&app, Request::get("/task/7")).unwrap(); //! assert!(matches!(answer.outcome, Outcome::Screen(_))); //! ``` //! //! # What is settled, and where it is written down //! //! The design lives on the wiki note `quasi-overview`, and the decisions this //! crate implements are numbered there. In short: //! //! - **An action is a route** (2). One address space for reads and writes, and //! the verb separates them. See [`Method`]. //! - **The screen tree lives here, not in `makeover-layout`** (3). It will churn //! while the router is proven against a second host, and a route is an address, //! which is the one thing the description layer never names. See [`Screen`]. //! - **An opaque region is filled per host** (4). See //! [`RegionKind::Handover`] and [`RegionKind::Ceded`]. //! - **The router is sync** (6). See [`Handler`]. //! - **A response carries a target, and may carry a notice or a redirect** (7). //! See [`Response`] and [`Outcome`]. //! - **`Router`, generic over app state** (8). See [`Router`]. //! - **Failure is classified** (9). See [`RouteError`]. //! //! # What a handler owes the first paint //! //! `makeover-layout`'s header states the rule ("First paint is final paint"): //! nothing resizes after it is drawn, and nothing stands in for content that has //! not arrived. This crate is the side that can break it, because a handler //! decides what the description knows before a renderer ever sees it. //! //! Two obligations follow, and both are the handler's rather than the renderer's. //! //! **Return the screen filled.** A handler is sync and returns a whole //! [`Screen`], so the data is in hand before any markup exists and there is //! nothing to wait for. `Readiness::Pending` describes a region that changes //! later, and a handler reaching for it on the way out is describing a moment //! that did not happen. //! //! That is a rule about a *first paint*, and [`Outcome::Started`] is not one. //! A write that has been handed off is a moment that did happen: the reader //! pressed something, work is running, and the region it will fill has nothing //! in it yet. What makes the two different is that the screen is already up. //! Answering an arrival with a pending region is describing a wait nobody //! experienced; answering a write with one is the only honest thing to say. //! //! **Pay for the count, or say you never will.** Anywhere the description //! carries an optional measurement, the `Option` is a fact about the query and //! not about the clock. A handler that wants the reader to see a total runs the //! count before it returns; one that will not pay for the count leaves it empty //! permanently and gets a shape that reads honestly without it. Filling it in on //! a later pass is the one thing forbidden, because the number arrives wider //! than the space left for it. pub mod chrome; pub mod containment; pub mod error; pub mod frame; mod path; pub mod renderer; pub mod request; pub mod response; pub mod router; pub mod screen; pub mod stage; /// The description layer, re-exported. /// /// Every intent a [`Screen`] composes is `makeover-layout`'s, and a consumer /// needs them to build one. Re-exported so that an app and a renderer are /// provably reading the same version of the vocabulary rather than two /// semver-compatible ones that happen to resolve together. pub use makeover_layout as layout; pub use crate::chrome::{Band, Binding, Brand, Chrome, Disclose, Panel, Place, Role}; pub use crate::containment::{Containment, Element, Level, Of}; pub use crate::error::{Class, RouteError}; pub use crate::frame::Frame; pub use crate::renderer::Renderer; pub use crate::request::{Method, Params, Request}; pub use crate::response::{ Address, Anchor, Invalidated, Locating, Message, Outcome, Picked, Response, Sought, safe_file_name, }; pub use crate::router::{Handler, Router}; pub use crate::screen::{ Accepted, Act, Action, Adds, Answer, Bar, CUTOFFS, Candidate, Canvas, Cell, CellKey, Chart, Choice, Choosing, Clock, Column, Consult, Curve, Destination, Discovery, Document, Feed, FeedKind, Field, Figure, Held, Image, Instance, Jump, Meter, Node, Outline, Placed, Prefill, Progress, Prose, Question, Ranked, RegionKind, Repeat, Repeating, Replaces, Rest, Reveal, Richness, Row, Run, Screen, Slot, SocialKind, Table, Tag, ThemeChoice, Trust, folded, folded_by, writable_root_attr, }; // `Frame` is deliberately not re-exported here: `crate::frame::Frame` is what a // mount puts around a screen and `chrome::Panel` is a place in it, both older // words for other things. A selective region's member is // `quasi_router::screen::Frame`, spelled through its module. pub use crate::screen::{Body, Picks}; #[cfg(test)] mod tests { use super::*; use crate::layout::{Arrangement, Tone}; /// The smallest app state a route can be written against. struct State { greeting: &'static str, } fn home(state: &State, _request: Request) -> Result { Ok(Screen::sidebar_content("Home") .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting))) .into()) } fn new_task(_state: &State, _request: Request) -> Result { Ok(Response::fragment("detail", Node::text("a new task"))) } // Taken by value because [`Handler`] says so, and a handler that only reads // its parameters is the common case rather than an oversight. #[allow(clippy::needless_pass_by_value)] fn show_task(_state: &State, request: Request) -> Result { let id = request.captures.require("id")?.to_owned(); Ok(Response::fragment("detail", Node::text(id))) } fn forbidden(_state: &State, _request: Request) -> Result { Err(RouteError::denied("not yours")) } fn router() -> Router { // Deliberately registered least-specific-first, so the ordering being // tested is the table's own and not the order of these lines. Router::new() .get("/task/{id}", show_task) .get("/task/new", new_task) .get("/", home) .post("/task/{id}/delete", forbidden) } fn state() -> State { State { greeting: "hello" } } fn text_of(response: &Response) -> Option<&str> { match &response.outcome { Outcome::Fragment { node: Node::Text { text, .. }, .. } => Some(text), _ => None, } } #[test] fn a_static_route_beats_a_capture_whatever_the_order() { let answer = router() .handle(&state(), Request::get("/task/new")) .unwrap(); assert_eq!(text_of(&answer), Some("a new task")); } #[test] fn a_capture_reaches_the_handler() { let answer = router().handle(&state(), Request::get("/task/7")).unwrap(); assert_eq!(text_of(&answer), Some("7")); } #[test] fn the_path_capture_wins_over_a_supplied_value() { // The path is the address; the body is only what was sent to it. let sent = Params::new().with("id", "9"); let answer = router() .handle(&state(), Request::get("/task/7").carrying(sent)) .unwrap(); assert_eq!(text_of(&answer), Some("7")); } #[test] fn a_read_and_a_write_are_different_routes_at_one_address() { let router = router(); let missing = router .handle(&state(), Request::post("/task/7")) .unwrap_err(); // `1e35bc8a`: not a `NotFound`, which it was until 2026-08-29. The // address is there and the verb is not, and saying the address is gone // is how a crawler drops a page a host is serving. assert_eq!(missing.class, Class::Unsupported); assert_eq!(missing.class.http_status(), 405); assert!(missing.message.contains("another method")); } #[test] fn a_verb_that_misses_names_the_verbs_that_would_not_have() { let refused = Router::::new() .get("/pricing", home) .post("/pricing", home) .handle( &state(), Request { method: Method::Delete, ..Request::get("/pricing") }, ) .unwrap_err(); assert_eq!(refused.class, Class::Unsupported); assert_eq!(refused.allow, vec![Method::Get, Method::Post]); // The spelling an HTTP host puts in the header, built here so that two // adapters cannot disagree about the separator. assert_eq!(refused.allow_header().as_deref(), Some("GET, POST")); } #[test] fn the_allow_list_does_not_depend_on_the_order_routes_were_registered() { let late = Router::::new() .post("/pricing", home) .get("/pricing", home) .handle( &state(), Request { method: Method::Delete, ..Request::get("/pricing") }, ) .unwrap_err(); assert_eq!(late.allow_header().as_deref(), Some("GET, POST")); } #[test] fn an_address_that_is_simply_absent_claims_no_verbs() { let missing = router() .handle(&state(), Request::get("/nowhere")) .unwrap_err(); // Empty is "no claim", and an adapter reads it as "send no `Allow`". // A 404 that named verbs would be describing a page that is not there. assert!(missing.allow.is_empty()); assert!(missing.allow_header().is_none()); } #[test] fn the_verbs_at_an_address_can_be_asked_for_directly() { assert_eq!(router().verbs_at("/task/new"), vec![Method::Get]); assert_eq!(router().verbs_at("/task/7/delete"), vec![Method::Post]); assert!(router().verbs_at("/nowhere").is_empty()); } #[test] fn an_unknown_path_says_so_without_mentioning_a_method() { let missing = router() .handle(&state(), Request::get("/nowhere")) .unwrap_err(); assert_eq!(missing.class, Class::NotFound); assert!(!missing.message.contains("another method")); } #[test] fn a_denial_carries_a_banner_and_a_status() { let denied = router() .handle(&state(), Request::post("/task/7/delete")) .unwrap_err(); assert_eq!(denied.class, Class::Denied); assert_eq!(denied.class.http_status(), 403); assert_eq!(denied.notice, layout::Notice::Banner); assert_eq!(denied.tone(), Tone::Warning); assert!(!denied.class.is_ours()); } #[test] fn a_missing_parameter_is_our_bug_not_the_users() { // Reached only by calling the handler outside the router, which is what // a renderer emitting an unfilled route amounts to. let missing = show_task(&state(), Request::get("/task/7")).unwrap_err(); assert_eq!(missing.class, Class::Internal); assert!(missing.class.is_ours()); } #[test] fn a_screen_answers_with_no_target_and_a_fragment_with_one() { let router = router(); let screen = router.handle(&state(), Request::get("/")).unwrap(); assert_eq!(screen.target(), None); let fragment = router.handle(&state(), Request::get("/task/7")).unwrap(); assert_eq!(fragment.target(), Some("detail")); } #[test] fn the_route_table_reads_back_most_specific_first() { let router = router(); let table: Vec<_> = router.routes().collect(); let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap(); let id_at = table.iter().position(|(_, p)| *p == "/task/{id}").unwrap(); assert!(new_at < id_at); assert_eq!(router.len(), 4); } #[test] #[should_panic(expected = "registered twice")] fn registering_one_route_twice_is_a_bug() { let _ = Router::::new() .get("/task/{id}", show_task) .get("/task/{id}", show_task); } #[test] fn a_slot_is_found_at_any_depth() { let screen = Screen::new("Tabs", Arrangement::list_detail(true)).with( Slot::new("tabs", RegionKind::TabGroup) .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), ); assert!(screen.slot("tabs").is_some()); assert!(screen.slot("pane-a").is_some()); assert!(screen.slot("pane-b").is_none()); } #[test] fn a_fragment_replaces_a_top_level_regions_contents() { let mut screen = Screen::sidebar_content("Home").with( Slot::new("content", RegionKind::Pane) .with(Node::text("first")) .with(Node::text("second")), ); assert!(screen.replace("content", Node::text("after"))); let slot = screen.slot("content").expect("the region is still there"); // Replaced, not appended: a fragment is one region's new contents, // which is the whole reason it can be smaller than a screen. assert_eq!(slot.body, Body::All(vec![Ranked::new(Node::text("after"))])); } #[test] fn a_fragment_reaches_a_region_nested_inside_another() { let mut screen = Screen::new("Tabs", Arrangement::list_detail(true)).with( Slot::new("tabs", RegionKind::TabGroup) .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), ); assert!(screen.replace("pane-a", Node::text("loaded"))); let pane = screen.slot("pane-a").expect("the nested region"); assert_eq!( pane.body, Body::All(vec![Ranked::new(Node::text("loaded"))]) ); // The region it is inside keeps its own body, which still holds the // nested region rather than having been replaced by it. let tabs = screen.slot("tabs").expect("the outer region"); assert_eq!(tabs.body.len(), 1); } #[test] fn a_pending_region_stops_being_pending_when_its_content_arrives() { let mut screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane).pending()); assert!(screen.replace("content", Node::text("here"))); let slot = screen.slot("content").expect("the region"); assert_eq!(slot.readiness, layout::Readiness::Ready); } #[test] fn a_fragment_for_a_region_that_is_not_there_answers_false() { let mut screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane)); // Not a panic and not a silent no-op: a miss means a route naming a // slot that no longer exists, so the caller is the one that can act on // it -- redraw, or fail a test. assert!(!screen.replace("detail", Node::text("nowhere"))); assert!(screen.slot("content").expect("untouched").body.is_empty()); } #[test] fn an_owned_field_borrows_back_as_the_description_layers_own() { let field = Field::select( "priority", "Priority", vec![Choice::plain("high"), Choice::new("low", "Low")], ) .required() .error("pick one"); field.with_layout(|borrowed| { assert_eq!(borrowed.name, "priority"); assert_eq!(borrowed.options.len(), 2); assert_eq!(borrowed.options[1].label, "Low"); assert!(borrowed.required); // The description layer's own reading of the same state. assert!(borrowed.invalid()); assert!(borrowed.kind.offers_options()); }); } #[test] fn the_opaque_regions_are_the_undescribed_ones() { assert!(RegionKind::Pane.described()); assert!( !RegionKind::Handover { name: "day-plan".into() } .described() ); } #[test] fn a_group_contains_a_section_and_claims_nothing_else() { use crate::containment::{Containment, Element}; let group = Slot::group("appearance") .with(Node::section("Appearance")) .with(Node::text("Theme")); assert_eq!(group.kind, RegionKind::Group); assert!(group.kind.described()); // The point of the member. A pane is looked into and scrolls; a group // does neither, so a settings screen's four groups are not four wells. assert_eq!(group.kind.depth(), layout::Depth::Flat); assert_eq!(RegionKind::Pane.depth(), layout::Depth::Well); // Blocks, by the ladder's third rule, with no special case needed: the // heading and the fields under it are ordinary body nodes. assert_eq!(group.containment(), Containment::Blocks); assert_eq!(group.body.len(), 2); } #[test] fn a_screen_nobody_described_is_indexable() { // `bool::default()` is false, so a derived Default here would deindex // every screen that never mentioned the subject. The impl is written // out for exactly this, and this is the assertion that keeps it. assert!(Discovery::default().indexable); assert!(Screen::sidebar_content("Home").discovery.indexable); assert!( !Screen::sidebar_content("Home") .indexed(false) .discovery .indexable ); } #[test] fn every_social_kind_has_a_distinct_spelling() { // A kind added upstream without a spelling fails here rather than // reaching a crawler as an og:type nothing recognises. let kinds = [ SocialKind::Website, SocialKind::Article, SocialKind::Profile, SocialKind::Product, SocialKind::Video, SocialKind::Song, ]; let mut seen = Vec::new(); for kind in kinds { let spelling = kind.as_str(); assert!(!spelling.is_empty(), "{kind:?} spells nothing"); assert!(!seen.contains(&spelling), "{spelling} is spelled twice"); seen.push(spelling); } assert_eq!(SocialKind::default(), SocialKind::Website); } #[test] fn every_feed_kind_has_a_distinct_media_type() { // A kind added without a spelling fails here rather than reaching a // reader as an `application/rss` nothing subscribes to. let kinds = [FeedKind::Rss, FeedKind::Atom, FeedKind::JsonFeed]; let mut seen = Vec::new(); for kind in kinds { let spelling = kind.media_type(); assert!(!spelling.is_empty(), "{kind:?} spells nothing"); assert!(!seen.contains(&spelling), "{spelling} is spelled twice"); seen.push(spelling); } assert_eq!(FeedKind::default(), FeedKind::Rss); } #[test] fn a_screen_says_its_feed_once_and_nobody_says_it_is_offering_one() { // The default has to be no feed, or every screen claims one. assert!(Discovery::default().feed.is_none()); let screen = Screen::single("Blog").syndicating(Feed::new( FeedKind::Rss, "Project updates", "/p/thing/feed.xml", )); let feed = screen.discovery.feed.as_ref().expect("said"); assert_eq!(feed.kind.media_type(), "application/rss+xml"); assert_eq!(feed.title, "Project updates"); assert_eq!(feed.href, "/p/thing/feed.xml"); } #[test] fn a_screen_says_where_the_caret_starts_and_most_screens_say_nothing() { // `None` is nearly every screen, which is every screen the reader // arrives at to read. assert!(Screen::single("Home").opens_at.is_none()); let login = Screen::single("Log in").opening_at("email"); assert_eq!(login.opens_at.as_deref(), Some("email")); } #[test] fn an_overlay_targets_no_region_and_sends_the_user_nowhere() { // The two questions a host asks before it looks for a body. An overlay // answers no to both: it is not aimed at a region, and dismissing it // reveals the screen the user never left. let answer = Response::over(Screen::sidebar_content("Palette")); assert!(answer.target().is_none()); assert!(answer.destination().is_none()); assert!(matches!(answer.outcome, Outcome::Over(_))); } #[test] fn chrome_is_held_beside_the_router_rather_than_arriving_with_an_answer() { // The claim `Chrome` makes, as a type: it is built once and no // `Response` carries one, so an affordance available everywhere cannot // be a fact about one answer. let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette")); assert_eq!( chrome.bound("ctrl+k").map(|b| b.label.as_str()), Some("Search") ); assert_eq!( chrome.bound("ctrl+k").map(|b| &b.action), Some(&Action::get("/palette")) ); } #[test] fn a_field_asks_for_room_the_way_a_column_does() { use crate::layout::Width; // `6d6a9160`, settled 2026-08-16: fill is determined at the description // stage. `Column::width` has carried this fact about a table cell since // the beginning and `Share` carries it about a region, so a leaf control // having no way to say it was an inconsistency rather than a principle. // The default is `Fill`, matching `Column::new`, so the member is // additive: a description written before it existed draws exactly as it // did. `Content` would have been the tidier reading and would have // narrowed every field in every app on the day it landed. let quiet = Field::new(layout::FieldKind::Text, "query", "Search"); assert_eq!(quiet.width, Width::Fill); assert_eq!(quiet.width, Column::new("Name").width); let sized = quiet.width(Width::Content); assert_eq!(sized.width, Width::Content); } /// The reader for the names a repeating question submits under: what a /// handler calls to get back the `Vec` its domain type holds. #[test] fn a_repeating_question_reads_back_as_a_list() { let params = Params::new() .with("title", "Standup") .with("reminder[1]", "900") .with("reminder[0]", "300") .with("reminder[2]", "3600"); // In slot order, whatever order the host sent them in. assert_eq!(params.repeated("reminder"), ["300", "900", "3600"]); // The rest of the form is untouched by it. assert_eq!(params.get("title"), Some("Standup")); assert!(params.repeated("title").is_empty()); // A hole is what a browser sends when the reader removed the middle // slot and nothing renumbered, and the answers are still the answers. let holed = Params::new() .with("reminder[0]", "300") .with("reminder[2]", "3600"); assert_eq!(holed.repeated("reminder"), ["300", "3600"]); // A question nobody answered, which is every slot removed. assert!(Params::new().repeated("reminder").is_empty()); // And a name that only looks like one belongs to nothing. let near = Params::new().with("reminders", "300"); assert!(near.repeated("reminder").is_empty()); } /// The same reader one level finer, for a slot that is several questions. #[test] fn a_grouped_slot_reads_back_one_part_at_a_time() { let params = Params::new() .with("note", "two files") .with("file[1].name", "b.wav") .with("file[1].size", "8") .with("file[0].name", "a.wav") .with("file[0].size", "4"); // In slot order, and one part at a time: pairing them by position is // the caller's, because only the caller knows it asked for both. assert_eq!(params.repeated_part("file", "name"), ["a.wav", "b.wav"]); assert_eq!(params.repeated_part("file", "size"), ["4", "8"]); // The two readers do not answer each other's names, which is what lets // a grouped question and an ordinary one share a form. assert!(params.repeated("file").is_empty()); assert!(params.repeated_part("file", "missing").is_empty()); assert_eq!(params.get("note"), Some("two files")); // A hole is a slot the reader removed, for `repeated`'s reason. let holed = Params::new() .with("file[0].name", "a.wav") .with("file[2].name", "c.wav"); assert_eq!(holed.repeated_part("file", "name"), ["a.wav", "c.wav"]); } }