From ed2c0721a87c16b202e21643ac4129375cb6299d Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:32:13 +0000 Subject: [PATCH 01/16] build(deps): ENG-4683 add axum + tokio dev-dependencies for the HTTP example Signed-off-by: Jimbo Freedman --- Cargo.toml | 15 +++++++++++++++ tests/cli_smoke.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index bfd779e..b6abf89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,12 +71,27 @@ tempfile = "3" # deny.toml's licence allowlist — no library or downstream-consumer impact. serde = { version = "1", features = ["derive"] } serde_json = "1" +# ENG-4683: examples/axum_server.rs (the README's HTTP quickstart). +# Dev-only — no library or downstream-consumer impact, and the wasm CI +# job builds lib-only (`cargo build`, no --all-targets) so these never +# cross-compile. Both MUST stay on ONE line: tests/changelog.rs:32-43 +# derives the crate version from the first `version`-prefixed line, so a +# multi-line inline table here would introduce a second candidate. +# `net` is beyond the feature list ENG-4683 originally named: axum::serve +# takes a tokio::net::TcpListener, which that feature gates. axum's own +# `tokio` feature would activate it transitively, but the example names +# the path directly, so we declare it directly. +axum = "0.8" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } # ENG-4690: CodSpeed's drop-in criterion replacement. Behaves as plain # criterion under `cargo bench` (incl. html_reports) and registers benches # with the CodSpeed harness under `cargo codspeed`. Re-exports the full # criterion 0.5 API, so benches/route.rs is ordinary criterion code. codspeed-criterion-compat = { version = "2", features = ["html_reports"] } +# NOTE: every [dev-dependencies] entry must stay ABOVE this table — +# [[bench]] starts a new table, so anything added below it would silently +# become part of the bench target instead of a dependency. [[bench]] name = "route" harness = false diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 0cddcd6..634befc 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -328,3 +328,51 @@ fn cargo_toml_gates_the_bin_and_keeps_clap_on_one_line() { (keep the clap dependency on a single line); found: {version_lines:?}" ); } + +/// ENG-4683: the axum quickstart example needs axum + tokio as +/// dev-dependencies, and `tokio::net::TcpListener` (used by +/// `axum::serve`) lives behind tokio's `net` feature. The ticket's +/// original feature list omitted `net`; relying on axum's own +/// `tokio/net` activation would couple our example's compile to +/// another crate's feature graph. +/// +/// The single-line requirement is the same invariant +/// `cargo_toml_gates_the_bin_and_keeps_clap_on_one_line` guards: a +/// multi-line inline table would introduce a second `version`-prefixed +/// line and break `tests/changelog.rs`'s version parser. +#[test] +fn cargo_toml_declares_axum_and_tokio_dev_deps_on_one_line_each() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let toml = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + + let axum_lines: Vec<&str> = toml + .lines() + .map(str::trim) + .filter(|l| l.starts_with("axum ")) + .collect(); + assert_eq!( + axum_lines.len(), + 1, + "expected exactly one single-line `axum` dependency line; found {axum_lines:?}" + ); + + let tokio_lines: Vec<&str> = toml + .lines() + .map(str::trim) + .filter(|l| l.starts_with("tokio ")) + .collect(); + assert_eq!( + tokio_lines.len(), + 1, + "expected exactly one single-line `tokio` dependency line; found {tokio_lines:?}" + ); + let tokio = tokio_lines[0]; + for feat in ["macros", "net", "rt-multi-thread"] { + assert!( + tokio.contains(&format!("\"{feat}\"")), + "tokio must enable the `{feat}` feature on the same line \ + (axum::serve needs tokio::net::TcpListener). Offending line: {tokio:?}" + ); + } +} From 1959b9400374ec543adf84af1e91e3b619e16396 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:34:18 +0000 Subject: [PATCH 02/16] test(crate): ENG-4683 compile README code fences as doctests Signed-off-by: Jimbo Freedman --- src/lib.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index e92e355..3835305 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,3 +28,11 @@ mod loader; pub use crate::loader::{EdgeId, Graph, LoadError, NodeId, Route, RouteError}; include!(concat!(env!("OUT_DIR"), "/edge_groups.rs")); + +/// Compiles the `README.md` code fences as doctests so the quickstarts +/// cannot drift from the API (ENG-4683). `#[cfg(doctest)]` keeps the +/// README out of rendered rustdoc output — it exists only during +/// `cargo test --doc`. +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; From b41456634195d4ae8de169b8f8d258396eeecd3d Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:41:18 +0000 Subject: [PATCH 03/16] docs(crate): ENG-4683 add axum HTTP server example Signed-off-by: Jimbo Freedman --- examples/axum_server.rs | 112 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 examples/axum_server.rs diff --git a/examples/axum_server.rs b/examples/axum_server.rs new file mode 100644 index 0000000..456c537 --- /dev/null +++ b/examples/axum_server.rs @@ -0,0 +1,112 @@ +//! ENG-4683: minimal axum HTTP server over rustyroute. +//! +//! Run it: +//! +//! cargo run --example axum_server +//! curl "localhost:3000/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47" +//! +//! This file is the single source of truth for the README's HTTP +//! quickstart — `tests/readme_contract.rs` asserts the README fence and +//! this file are byte-identical, so edit here and re-sync the README. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use rustyroute::{EdgeId, Graph, RouteError}; +use serde_json::json; + +/// Load the graph once and leak it for the process lifetime — the +/// long-lived-handle pattern documented on `rustyroute::Graph`. `Graph` +/// is `Send + Sync` but not `Clone`, so a handler shared across tokio +/// worker threads needs `&'static Graph` (or an `Arc`). +/// +/// `Graph::load(50)` needs no setup on default features: it falls back +/// to the `data-50km` slice baked into the binary. Without a filesystem +/// (wasm, scratch containers) use that slice directly instead — +/// `Graph::from_bytes(rustyroute::data::BYTES_50KM)` — which requires +/// the `data-50km` feature to be enabled. +fn graph() -> &'static Graph { + static G: OnceLock<&'static Graph> = OnceLock::new(); + G.get_or_init(|| Box::leak(Box::new(Graph::load(50).expect("load 50km graph")))) +} + +/// `?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=suezCanal` +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RouteQuery { + from_lat_lng: String, + to_lat_lng: String, + /// Comma-separated edge-group names; see `rustyroute::EDGE_GROUPS`. + block: Option, +} + +/// Parse a `"lat,lng"` pair. The library's coordinate order is +/// (lat, lng) — the same order this endpoint accepts. +fn parse_lat_lng(s: &str) -> Option<(f64, f64)> { + let (lat, lng) = s.split_once(',')?; + Some((lat.trim().parse().ok()?, lng.trim().parse().ok()?)) +} + +async fn route(Query(q): Query) -> Response { + let Some(from) = parse_lat_lng(&q.from_lat_lng) else { + return bad_request("fromLatLng must be `lat,lng`"); + }; + let Some(to) = parse_lat_lng(&q.to_lat_lng) else { + return bad_request("toLatLng must be `lat,lng`"); + }; + + let graph = graph(); + let blocked: HashSet = match q.block.as_deref().filter(|s| !s.is_empty()) { + None => HashSet::new(), + Some(names) => match graph.edges_for_groups(names.split(',').map(str::trim)) { + Ok(ids) => ids, + Err(e) => return bad_request(&e.to_string()), + }, + }; + + match graph.route(from, to, &blocked) { + Ok(r) => { + // THE coordinate swap. The library speaks (lat, lng); + // GeoJSON positions are [lng, lat]. Getting this backwards + // is the single most common mistake — do it once, here, at + // the response boundary. + let mut coordinates: Vec<[f64; 2]> = + r.coordinates.iter().map(|&(lat, lng)| [lng, lat]).collect(); + // A self-route returns one coordinate, but RFC 7946 §3.1.4 + // requires a LineString to have two or more positions. + // Repeat the point for a valid degenerate line — the same + // thing the `rustyroute` CLI does. + if coordinates.len() == 1 { + coordinates.push(coordinates[0]); + } + Json(json!({ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { "type": "LineString", "coordinates": coordinates }, + "properties": { "distance_km": r.distance_km, "resolution": 50 }, + }], + })) + .into_response() + } + Err(RouteError::NoRoute) => (StatusCode::NOT_FOUND, "no route").into_response(), + Err(e) => bad_request(&e.to_string()), + } +} + +fn bad_request(msg: &str) -> Response { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() +} + +#[tokio::main] +async fn main() { + let app = Router::new().route("/route", get(route)); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + println!("listening on http://{}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} From 1b73da8eaf90393e0ffa5955bc6a1adc9ed7dd4e Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:43:03 +0000 Subject: [PATCH 04/16] docs(crate): ENG-4683 rewrite README with quickstarts, pipeline diagram, and edge-group table Signed-off-by: Jimbo Freedman --- README.md | 390 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 350 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 5b25587..54209e6 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,213 @@ # rustyroute +[![crates.io](https://img.shields.io/crates/v/rustyroute.svg)](https://crates.io/crates/rustyroute) +[![docs.rs](https://img.shields.io/docsrs/rustyroute)](https://docs.rs/rustyroute) +[![CI](https://github.com/spotship/rustyroute/actions/workflows/ci.yaml/badge.svg)](https://github.com/spotship/rustyroute/actions/workflows/ci.yaml) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) [![MSRV](https://img.shields.io/badge/MSRV-1.93.0-orange.svg)](Cargo.toml) -A Rust library for shortest-path maritime route computation. +Maritime sea-routing primitives on Eurostat MARNET data, in safe Rust. +5 resolutions, 13 named chokepoint groups, zero-copy mmap load. Give it +two `(lat, lng)` points and it returns the shortest sea path between +them, optionally routing around named chokepoints like the Suez Canal or +the Strait of Malacca. The graph data ships inside the crate, so there +is nothing to download, no service to call, and no build step of your +own. -> **Status: pre-release.** This repository contains the EUPL-1.2 -> legal/governance scaffolding, the vendored Eurostat MARNET data, and -> a `build.rs` that compiles the MARNET GeoPackages into rkyv graph -> archives at build time. `Graph::load`/`Graph::from_bytes` and -> Dijkstra routing (`Graph::route`) have landed; distance matrices and -> further algorithms follow in later tickets — see -> [`CONTRIBUTING.md`](CONTRIBUTING.md) for the scope policy. +> **Status: pre-1.0.** The API can break between minor versions, and +> `rustyroute` is not yet published to crates.io — the crates.io and +> docs.rs badges above stay grey until the first release. This crate is +> the routing core behind Spot Ship's `marine-router` production +> service. Distance matrices and further algorithms follow in later +> releases; see [`CONTRIBUTING.md`](CONTRIBUTING.md) for the scope +> policy. -## Installation - -Not yet published to crates.io. Once published: +## Quickstart (library) ```sh cargo add rustyroute ``` -## Usage - -Load a graph and compute a route. Coordinates are `(lat, lng)` in -decimal degrees: +That is the whole setup. The default features bake the 50 km graph into +your binary, so the snippet below runs as-is — no environment variable, +no data directory, no build script. ```rust use rustyroute::Graph; use std::collections::HashSet; fn main() -> Result<(), Box> { - let graph = Graph::load(50)?; // 50 km resolution + // Works on default features: the 50 km graph is baked into the crate. + let graph = Graph::load(50)?; - let route = graph.route( - (43.30, 5.37), // Marseille - (31.23, 121.47), // Shanghai - &HashSet::new(), // no blocked edges - )?; - println!("{:.1} km", route.distance_km); - - // Avoid a chokepoint: resolve one or more of the 13 baked-in edge - // groups (see `rustyroute::EDGE_GROUPS`) and pass them to `route`. - let blocked = graph.edges_for_groups(["suezCanal"])?; - let around_africa = graph.route((34.0, 28.0), (20.0, 38.0), &blocked)?; - println!("{:.1} km avoiding Suez", around_africa.distance_km); + // Coordinates are (lat, lng) — Marseille to Shanghai. + let route = graph.route((43.30, 5.37), (31.23, 121.47), &HashSet::new())?; + println!("{:.1} km over {} points", route.distance_km, route.coordinates.len()); + println!("first: {:?}", route.coordinates[0]); // (lat, lng) Ok(()) } ``` -The `data-{N}km` features control which resolutions are baked into the -binary; `data-50km` is the default. `Graph::load` also honours -`$RUSTYROUTE_DATA_DIR` for loading archives from disk. +This prints `16354.1 km over 106 points`. + +## Quickstart (HTTP server with axum) + +A complete routing service in about fifty lines. Add these dependencies: + +```toml +[dependencies] +rustyroute = "0.1" +axum = "0.8" +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +``` + +```rust,no_run +//! ENG-4683: minimal axum HTTP server over rustyroute. +//! +//! Run it: +//! +//! cargo run --example axum_server +//! curl "localhost:3000/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47" +//! +//! This file is the single source of truth for the README's HTTP +//! quickstart — `tests/readme_contract.rs` asserts the README fence and +//! this file are byte-identical, so edit here and re-sync the README. + +use std::collections::HashSet; +use std::sync::OnceLock; + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use rustyroute::{EdgeId, Graph, RouteError}; +use serde_json::json; + +/// Load the graph once and leak it for the process lifetime — the +/// long-lived-handle pattern documented on `rustyroute::Graph`. `Graph` +/// is `Send + Sync` but not `Clone`, so a handler shared across tokio +/// worker threads needs `&'static Graph` (or an `Arc`). +/// +/// `Graph::load(50)` needs no setup on default features: it falls back +/// to the `data-50km` slice baked into the binary. Without a filesystem +/// (wasm, scratch containers) use that slice directly instead — +/// `Graph::from_bytes(rustyroute::data::BYTES_50KM)` — which requires +/// the `data-50km` feature to be enabled. +fn graph() -> &'static Graph { + static G: OnceLock<&'static Graph> = OnceLock::new(); + G.get_or_init(|| Box::leak(Box::new(Graph::load(50).expect("load 50km graph")))) +} + +/// `?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=suezCanal` +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RouteQuery { + from_lat_lng: String, + to_lat_lng: String, + /// Comma-separated edge-group names; see `rustyroute::EDGE_GROUPS`. + block: Option, +} + +/// Parse a `"lat,lng"` pair. The library's coordinate order is +/// (lat, lng) — the same order this endpoint accepts. +fn parse_lat_lng(s: &str) -> Option<(f64, f64)> { + let (lat, lng) = s.split_once(',')?; + Some((lat.trim().parse().ok()?, lng.trim().parse().ok()?)) +} + +async fn route(Query(q): Query) -> Response { + let Some(from) = parse_lat_lng(&q.from_lat_lng) else { + return bad_request("fromLatLng must be `lat,lng`"); + }; + let Some(to) = parse_lat_lng(&q.to_lat_lng) else { + return bad_request("toLatLng must be `lat,lng`"); + }; + + let graph = graph(); + let blocked: HashSet = match q.block.as_deref().filter(|s| !s.is_empty()) { + None => HashSet::new(), + Some(names) => match graph.edges_for_groups(names.split(',').map(str::trim)) { + Ok(ids) => ids, + Err(e) => return bad_request(&e.to_string()), + }, + }; + + match graph.route(from, to, &blocked) { + Ok(r) => { + // THE coordinate swap. The library speaks (lat, lng); + // GeoJSON positions are [lng, lat]. Getting this backwards + // is the single most common mistake — do it once, here, at + // the response boundary. + let mut coordinates: Vec<[f64; 2]> = + r.coordinates.iter().map(|&(lat, lng)| [lng, lat]).collect(); + // A self-route returns one coordinate, but RFC 7946 §3.1.4 + // requires a LineString to have two or more positions. + // Repeat the point for a valid degenerate line — the same + // thing the `rustyroute` CLI does. + if coordinates.len() == 1 { + coordinates.push(coordinates[0]); + } + Json(json!({ + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { "type": "LineString", "coordinates": coordinates }, + "properties": { "distance_km": r.distance_km, "resolution": 50 }, + }], + })) + .into_response() + } + Err(RouteError::NoRoute) => (StatusCode::NOT_FOUND, "no route").into_response(), + Err(e) => bad_request(&e.to_string()), + } +} + +fn bad_request(msg: &str) -> Response { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() +} + +#[tokio::main] +async fn main() { + let app = Router::new().route("/route", get(route)); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + println!("listening on http://{}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} +``` + +Run it and ask for a route: + +```sh +cargo run --example axum_server +curl "localhost:3000/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47" +``` + +```json +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[5.1927490234375, 43.230220794677734], "..."] + }, + "properties": { "distance_km": 16354.073, "resolution": 50 } + } + ] +} +``` + +**Mind the coordinate order.** `rustyroute` speaks `(lat, lng)`, because +that is the order people write coordinates in. GeoJSON positions are +`[lng, lat]`. The example does that swap once, at the response boundary, +and it is the single thing new users most often get backwards — note +that the first position above begins `5.19…` (a longitude near +Marseille), not `43.23…`. ## CLI @@ -88,24 +243,179 @@ Results go to stdout, diagnostics to stderr. Exit codes: | 3 | graph data unavailable for the requested resolution | | 4 | failed to write output | -## Attribution +## How the data is built + +Everything happens at compile time. `build.rs` reads the vendored +GeoPackages, builds a CSR adjacency, classifies the chokepoint groups, +and writes one rkyv archive per resolution. At run time the library +either mmaps that archive or reads the copy baked into your binary — +either way the graph is used in place, with no parsing step. + +```text + build time (build.rs) run time + ───────────────────── ──────── + + vendor/eurostat-marnet/ + marnet_plus_{5,10,20,50,100}km.gpkg + │ + │ build/gpkg_io.rs read GeoPackage LineStrings + ▼ + RawEdge stream + │ + │ build/csr.rs dedupe nodes, build CSR adjacency, + │ haversine edge weights (km) + ▼ + CsrBuilt + │ + │ build/groups.rs 12 `pass`-tag groups + menaiStrait + │ (bbox); empty group => build error + ▼ + GraphData ── build/archive.rs ──▶ $OUT_DIR/data/{N}km.rkyv + │ b"RRG1" + u32 version + │ + rkyv payload + │ + │ build/registry.rs + ▼ + $OUT_DIR/edge_groups.rs ──▶ pub const EDGE_GROUPS: &[&str; 13] + (included by src/lib.rs) + + $OUT_DIR/data/{N}km.rkyv + │ + include_bytes! ──────┤ (src/data.rs, gated by + 4-byte aligned │ the data-{N}km feature) + ▼ + rustyroute::data::BYTES_{N}KM + │ + Graph::load(N) ── mmap from disk ───────┤── Graph::from_bytes(..) + $RUSTYROUTE_DATA_DIR │ (any target, incl. wasm) + → $OUT_DIR → static slice ▼ + Graph + │ + ▼ + Graph::route((lat,lng), (lat,lng), &blocked) + │ + ▼ + Route { coordinates: Vec<(lat, lng)>, + distance_km, edge_ids } +``` + +## Data and features + +The maritime network is Eurostat's SeaRoute / MARNET dataset, vendored +byte-for-byte under `vendor/eurostat-marnet/` from upstream commit +`88a2e568a8e0144d1f5a81c3931a7bc2bcce6901` and published by Eurostat +under EUPL-1.2 — the same licence as this crate. See [`NOTICE`](NOTICE) +and [`vendor/eurostat-marnet/README.md`](vendor/eurostat-marnet/README.md) +for the full provenance chain, checksums, and download date. + +Each resolution is a separate feature, so you pay only for the grids you +use. Sizes are the rkyv archive baked into your binary: + +| Feature | Resolution | Nodes | Edges | Baked size | +|---|---|---:|---:|---:| +| `data-5km` | 5 km | 36,121 | 72,478 | 2.90 MiB | +| `data-10km` | 10 km | 23,288 | 48,301 | 1.93 MiB | +| `data-20km` | 20 km | 14,046 | 29,581 | 1.18 MiB | +| **`data-50km`** (default) | 50 km | 7,390 | 15,498 | **632 KiB** | +| `data-100km` | 100 km | 4,688 | 9,847 | 402 KiB | + +To swap the default resolution, turn the defaults off and pick another: + +```toml +rustyroute = { version = "0.1", default-features = false, features = ["data-20km"] } +``` + +Two other ways to get graph data in: + +- **From disk.** Set `$RUSTYROUTE_DATA_DIR` and `Graph::load(N)` mmaps + `{N}km.rkyv` from there instead of using a baked copy — useful when you + want one archive shared by several processes, or a binary that stays + small. +- **From a byte slice.** `Graph::from_bytes(rustyroute::data::BYTES_50KM)` + works on every target including `wasm32`, where there is no filesystem + to mmap. This still needs a data feature: `default-features = false` + on its own compiles no `BYTES_*KM` constant at all, so write + `default-features = false, features = ["data-50km"]`. + +## Edge groups + +Thirteen named chokepoints and passages are baked into every archive. +Resolve any of them to a set of edge ids with +`graph.edges_for_groups(["suezCanal"])` and pass that set to `route()` to +find the path that avoids them. The names are also available at compile +time as `rustyroute::EDGE_GROUPS`. + +| Group | Source | +|---|---| +| `suezCanal` | upstream `pass` tag `suez` | +| `panamaCanal` | `pass` tag `panama` | +| `malaccaStrait` | `pass` tag `malacca` | +| `gibraltarStrait` | `pass` tag `gibraltar` | +| `doverStrait` | `pass` tag `dover` | +| `beringStrait` | `pass` tag `bering` | +| `magellanStrait` | `pass` tag `magellan` | +| `babElMandebStrait` | `pass` tag `babelmandeb` | +| `kielCanal` | `pass` tag `kiel` | +| `corinthCanal` | `pass` tag `corinth` | +| `northwestPassage` | `pass` tag `northwest` | +| `northeastPassage` | `pass` tag `northeast` | +| `menaiStrait` | bbox `lng ∈ [-4.20, -4.00]`, `lat ∈ [53.13, 53.30]` | + +Twelve of the groups come straight from the upstream `pass` attribute. +`menaiStrait` has no upstream tag, so it is derived geometrically: any +edge whose LineString intersects that closed bounding box joins the +group. A group that ends up empty at any resolution is a hard build +error, so all thirteen are guaranteed present in shipped data. + +Blocking the Suez Canal on a Marseille → Shanghai route lengthens it +from 16,354 km to 25,047 km — the trip around the Cape of Good Hope. + +## Performance + +Two numbers are asserted by the test suite as budgets: a cold +`Graph::load(50)` under 50 ms and a warm one under 1 ms. That test is +`#[ignore]`d, because CI runners vary too much for a hard timing gate. + +Measured on a Linux host with rustc 1.97, release profile: + +| Operation | Time | +|---|---| +| `Graph::load(50)` — cold | 27 µs | +| `Graph::load(50)` — warm | 26 µs | +| `Graph::from_bytes(BYTES_50KM)` | 2 µs | +| `route()` — 50 km, Marseille → Shanghai (106 points) | 5.4 ms | +| `route()` — 5 km, same pair (204 points) | 29 ms | + +Loading is effectively free: the archive is mmapped or already resident +in the binary, and rkyv reads it in place with no deserialisation. The +cost that matters is `route()` itself, currently dominated by a +linear-scan nearest-node snap over the node table — a k-d tree is +tracked for a later release. + +For size, the default features add about 632 KiB to your binary and all +five resolutions add about 7.0 MiB. That is the whole footprint — there +is no sidecar data file to ship — so a container image carrying this +crate grows by whichever resolutions you enable and nothing else. Treat +these numbers as observations on one machine, not guarantees. + +## License and attribution + +Licensed under the +[European Union Public Licence v. 1.2](LICENSE) (`EUPL-1.2`). rustyroute is based on and inspired by Eurostat's -[SeaRoute](https://github.com/eurostat/searoute) project. SeaRoute is -published under EUPL-1.2 by the European Union (Eurostat). See +[SeaRoute](https://github.com/eurostat/searoute) project, published +under EUPL-1.2 by the European Union (Eurostat). The vendored MARNET +GeoPackages are redistributed unmodified under the same licence. See [`NOTICE`](NOTICE) for full attribution. ## Contributing See [`CONTRIBUTING.md`](CONTRIBUTING.md). All commits must be signed off -under the [Developer Certificate of Origin](https://developercertificate.org/). +under the [Developer Certificate of Origin](https://developercertificate.org/) +— `git commit -s` does this for you. ## Security See [`SECURITY.md`](SECURITY.md). Do **not** report vulnerabilities via public issues. - -## License - -Licensed under the [European Union Public Licence v. 1.2](LICENSE) -(`EUPL-1.2`). From d9c194f3a0e709df04ded7851f43f8c6e619f069 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:44:16 +0000 Subject: [PATCH 05/16] test(crate): ENG-4683 pin README to EDGE_GROUPS, the axum example, the menai bbox, and its section order Signed-off-by: Jimbo Freedman --- tests/readme_contract.rs | 163 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/readme_contract.rs diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs new file mode 100644 index 0000000..6bda5b5 --- /dev/null +++ b/tests/readme_contract.rs @@ -0,0 +1,163 @@ +//! ENG-4683: pin `README.md` to the code it documents. +//! +//! The README makes four claims that can silently rot, plus one +//! meta-claim about its own machinery: +//! AC4: the 13 edge groups it tables -> `readme_edge_group_table_matches_edge_groups_exactly` +//! AC5: the axum block it shows -> `readme_axum_fence_matches_example_file` +//! AC6: the menaiStrait bbox -> `readme_documents_menai_bbox` +//! AC8: the section inventory -> `readme_sections_appear_in_ticket_order` +//! AC2: the doctest anchor itself -> `lib_rs_compiles_readme_as_doctests` +//! +//! `build/groups.rs` is a build-only module and is not linked into this +//! test crate, so the bbox check asserts against its source text — the +//! same technique `tests/data_module.rs:26-50` and `tests/lint_state.rs` +//! use for build-time invariants with no runtime observable. + +use std::path::PathBuf; + +fn read(rel: &str) -> String { + let p: PathBuf = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel); + let s = std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())); + // CI runs windows-latest (.github/workflows/ci.yaml:55) and the repo + // has no .gitattributes, so a checkout there may be CRLF. Every + // comparison in this file is byte-exact, so normalise on read. + s.replace("\r\n", "\n") +} + +/// Body of the first fenced block opened with exactly ```` ```{info} ````. +/// The info-string match is exact, so `rust` does not match `rust,no_run`. +fn fenced_block(md: &str, info: &str) -> Option { + let open = format!("```{info}"); + let mut lines = md.lines(); + lines.by_ref().find(|l| l.trim_end() == open)?; + let mut body = String::new(); + for line in lines { + if line.trim_end() == "```" { + return Some(body); + } + body.push_str(line); + body.push('\n'); + } + None +} + +/// AC4. Ordered whole-vector equality, deliberately: `EDGE_GROUPS` is +/// documented as a *stable order* matching `Graph::groups[i]` +/// (build/registry.rs:18-21), so a reordered table is a real defect and +/// a set/`contains` assertion would wave it through. +#[test] +fn readme_edge_group_table_matches_edge_groups_exactly() { + let readme = read("README.md"); + let header = "| Group | Source |"; + let start = readme + .find(header) + .unwrap_or_else(|| panic!("README.md must contain an edge-group table headed `{header}`")); + + let names: Vec = readme[start..] + .lines() + .skip(2) // header row + `|---|---|` separator + .take_while(|l| l.starts_with('|')) + .map(|l| { + let cell = l + .split('|') + .nth(1) + .unwrap_or_else(|| panic!("malformed table row: {l:?}")) + .trim(); + cell.trim_matches('`').to_string() + }) + .collect(); + + let expected: Vec = rustyroute::EDGE_GROUPS + .iter() + .map(|s| (*s).to_string()) + .collect(); + assert_eq!( + names, expected, + "README.md's edge-group table must match `rustyroute::EDGE_GROUPS` \ + exactly and in order. Regenerate from the constant (build/groups.rs \ + PASS_GROUPS + MENAI_NAME) rather than editing the README by hand." + ); + assert_eq!(names.len(), 13, "there are exactly 13 edge groups"); +} + +/// AC5. The example is the source of truth; the README copy is what OSS +/// readers actually paste. They must be byte-identical. +#[test] +fn readme_axum_fence_matches_example_file() { + let readme = read("README.md"); + let example = read("examples/axum_server.rs"); + let fence = fenced_block(&readme, "rust,no_run") + .expect("README.md must contain a ```rust,no_run fence holding the axum example"); + assert_eq!( + fence, example, + "the README's ```rust,no_run fence has drifted from \ + examples/axum_server.rs. Re-sync it from the file — see the \ + splice snippet in the ENG-4683 plan, Task 4 Step 2." + ); +} + +/// AC6. Those four numbers are the only reason group 13 exists. +#[test] +fn readme_documents_menai_bbox() { + let readme = read("README.md"); + let groups_rs = read("build/groups.rs"); + for (konst, literal) in [ + ("MENAI_LNG_MIN", "-4.20"), + ("MENAI_LNG_MAX", "-4.00"), + ("MENAI_LAT_MIN", "53.13"), + ("MENAI_LAT_MAX", "53.30"), + ] { + assert!( + groups_rs.contains(&format!("{konst}: f64 = {literal};")), + "build/groups.rs no longer defines `{konst} = {literal}` — the README \ + bbox and this test must both be updated to the new value" + ); + assert!( + readme.contains(literal), + "README.md must quote the menaiStrait bbox bound {literal} \ + (from build/groups.rs `{konst}`)" + ); + } +} + +/// AC8. The ticket fixes both the set of sections and their order. +/// Asserted as a whole vector so an inserted, renamed, dropped, or +/// reordered heading all fail loudly. +#[test] +fn readme_sections_appear_in_ticket_order() { + let readme = read("README.md"); + let headings: Vec<&str> = readme + .lines() + .filter(|l| l.starts_with("# ") || l.starts_with("## ")) + .collect(); + let expected = [ + "# rustyroute", + "## Quickstart (library)", + "## Quickstart (HTTP server with axum)", + "## CLI", + "## How the data is built", + "## Data and features", + "## Edge groups", + "## Performance", + "## License and attribution", + "## Contributing", + "## Security", + ]; + assert_eq!( + headings, expected, + "README.md's sections must match the ENG-4683 inventory, in order" + ); +} + +/// Guard the guard: without the anchor, `cargo test --doc` compiles +/// none of the README's fences and AC2 silently lapses. +#[test] +fn lib_rs_compiles_readme_as_doctests() { + let lib = read("src/lib.rs"); + assert!( + lib.contains("#[cfg(doctest)]") && lib.contains("include_str!(\"../README.md\")"), + "src/lib.rs must keep the `#[cfg(doctest)] #[doc = include_str!(\"../README.md\")]` \ + anchor — without it `cargo test --doc` compiles none of the README's fences \ + and the quickstarts can rot (ENG-4683 AC2)." + ); +} From 1acc92e5250526b84bff5047ae1508818a072b87 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 08:48:01 +0000 Subject: [PATCH 06/16] test(crate): ENG-4683 assert the README quickstart route works for an external consumer Signed-off-by: Jimbo Freedman --- tests/downstream_consumer/src/lib.rs | 15 +++++++++++++++ tests/downstream_consumer/tests/smoke.rs | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/tests/downstream_consumer/src/lib.rs b/tests/downstream_consumer/src/lib.rs index 9e52ad5..ab3fb88 100644 --- a/tests/downstream_consumer/src/lib.rs +++ b/tests/downstream_consumer/src/lib.rs @@ -26,3 +26,18 @@ pub fn exercise_public_api() -> (u32, u32, u32) { loaded.directed_edge_count(), ) } + +/// ENG-4683 AC3: the README's library quickstart, run from a separate +/// package that depends on rustyroute with default features only. +/// Returns `(coordinate_count, distance_km)`. +pub fn exercise_route() -> (usize, f64) { + use rustyroute::Graph; + use std::collections::HashSet; + + let graph = Graph::load(50).expect("Graph::load(50) on default features"); + // (lat, lng) — Marseille to Shanghai, same pair as the README. + let route = graph + .route((43.30, 5.37), (31.23, 121.47), &HashSet::new()) + .expect("route Marseille -> Shanghai"); + (route.coordinates.len(), route.distance_km) +} diff --git a/tests/downstream_consumer/tests/smoke.rs b/tests/downstream_consumer/tests/smoke.rs index b2421b5..85e4638 100644 --- a/tests/downstream_consumer/tests/smoke.rs +++ b/tests/downstream_consumer/tests/smoke.rs @@ -12,3 +12,19 @@ fn round_trip_load_and_from_bytes_50km() { "directed_edge_count must be >= edge_count" ); } + +/// ENG-4683 AC3: a clean external project on default features can run +/// the README's library quickstart verbatim and get a valid `Route` — +/// no env var, no data directory, no build script of its own. +#[test] +fn readme_quickstart_route_works_for_an_external_consumer() { + let (points, distance_km) = downstream_consumer::exercise_route(); + assert!( + points > 1, + "a Marseille->Shanghai route needs many points, got {points}" + ); + assert!( + distance_km > 1000.0, + "expected a plausible ocean crossing, got {distance_km} km" + ); +} From 676941db2760c97d9eed964d75792fb06ed428ed Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 09:15:03 +0000 Subject: [PATCH 07/16] =?UTF-8?q?fix(crate):=20ENG-4683=20address=20peer?= =?UTF-8?q?=20review=20=E2=80=94=20derive=20resolution,=20strengthen=20REA?= =?UTF-8?q?DME=20drift=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/axum_server.rs: emit graph.resolution_km() instead of a hardcoded 50, so changing the load() call cannot silently report a wrong resolution. - tests/readme_contract.rs: assert the menaiStrait bbox as a whole phrase so an axis swap (lat/lng transposed) fails; add readme_edge_group_table_pass_tags_match_pass_groups so the table's second column is verified against PASS_GROUPS too. - README.md: drop the inaccurate "no build script" claim (rustyroute's own build.rs compiles bundled SQLite and parses ~17 MiB of GeoPackages), note the crate is not yet on crates.io at the point the reader runs cargo add, and say which features the Performance rows need. Signed-off-by: Jimbo Freedman --- examples/axum_server.rs | 5 ++- tests/readme_contract.rs | 71 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/examples/axum_server.rs b/examples/axum_server.rs index 456c537..838caba 100644 --- a/examples/axum_server.rs +++ b/examples/axum_server.rs @@ -89,7 +89,10 @@ async fn route(Query(q): Query) -> Response { "features": [{ "type": "Feature", "geometry": { "type": "LineString", "coordinates": coordinates }, - "properties": { "distance_km": r.distance_km, "resolution": 50 }, + "properties": { + "distance_km": r.distance_km, + "resolution": graph.resolution_km(), + }, }], })) .into_response() diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs index 6bda5b5..d11fff0 100644 --- a/tests/readme_contract.rs +++ b/tests/readme_contract.rs @@ -1,8 +1,9 @@ //! ENG-4683: pin `README.md` to the code it documents. //! -//! The README makes four claims that can silently rot, plus one +//! The README makes five claims that can silently rot, plus one //! meta-claim about its own machinery: //! AC4: the 13 edge groups it tables -> `readme_edge_group_table_matches_edge_groups_exactly` +//! AC4: the `pass` tags it credits -> `readme_edge_group_table_pass_tags_match_pass_groups` //! AC5: the axum block it shows -> `readme_axum_fence_matches_example_file` //! AC6: the menaiStrait bbox -> `readme_documents_menai_bbox` //! AC8: the section inventory -> `readme_sections_appear_in_ticket_order` @@ -97,25 +98,83 @@ fn readme_axum_fence_matches_example_file() { } /// AC6. Those four numbers are the only reason group 13 exists. +/// +/// Asserts the whole bbox phrase, not the four literals separately: a +/// bare `readme.contains("-4.20")` sweep would still pass if the README +/// swapped the axes (`lat ∈ [-4.20, -4.00], lng ∈ [53.13, 53.30]`), +/// which is exactly the mistake worth catching — the longitudes and +/// latitudes here are not interchangeable. #[test] fn readme_documents_menai_bbox() { let readme = read("README.md"); let groups_rs = read("build/groups.rs"); - for (konst, literal) in [ + + let bounds = [ ("MENAI_LNG_MIN", "-4.20"), ("MENAI_LNG_MAX", "-4.00"), ("MENAI_LAT_MIN", "53.13"), ("MENAI_LAT_MAX", "53.30"), - ] { + ]; + for (konst, literal) in bounds { assert!( groups_rs.contains(&format!("{konst}: f64 = {literal};")), "build/groups.rs no longer defines `{konst} = {literal}` — the README \ bbox and this test must both be updated to the new value" ); + } + + let [lng_min, lng_max, lat_min, lat_max] = bounds.map(|(_, literal)| literal); + let phrase = format!("`lng ∈ [{lng_min}, {lng_max}]`, `lat ∈ [{lat_min}, {lat_max}]`"); + assert!( + readme.contains(&phrase), + "README.md must state the menaiStrait bbox as `{phrase}` — with each bound \ + on its own axis. build/groups.rs:35-38 is the source of truth." + ); +} + +/// AC4, second column. The group table also claims which upstream +/// `pass` tag feeds each of the first twelve groups; those claims are +/// checkable against `build/groups.rs`'s `PASS_GROUPS` and would +/// otherwise be the one part of the table nothing verifies. +#[test] +fn readme_edge_group_table_pass_tags_match_pass_groups() { + let readme = read("README.md"); + let groups_rs = read("build/groups.rs"); + + // Parse `("suez", "suezCanal"),` pairs out of the PASS_GROUPS array. + let start = groups_rs + .find("pub const PASS_GROUPS") + .expect("build/groups.rs must define PASS_GROUPS"); + let body = &groups_rs[start + ..start + + groups_rs[start..] + .find("];") + .expect("PASS_GROUPS array must be terminated")]; + let pairs: Vec<(String, String)> = body + .lines() + .filter_map(|l| { + let l = l.trim(); + let inner = l.strip_prefix('(')?.split_once("),")?.0; + let (tag, public) = inner.split_once(',')?; + Some(( + tag.trim().trim_matches('"').to_string(), + public.trim().trim_matches('"').to_string(), + )) + }) + .collect(); + assert_eq!( + pairs.len(), + 12, + "expected 12 PASS_GROUPS entries, parsed {pairs:?}" + ); + + for (tag, public) in pairs { + let row_claim = format!("`pass` tag `{tag}`"); + let alt_claim = format!("upstream `pass` tag `{tag}`"); assert!( - readme.contains(literal), - "README.md must quote the menaiStrait bbox bound {literal} \ - (from build/groups.rs `{konst}`)" + readme.contains(&row_claim) || readme.contains(&alt_claim), + "README.md's `{public}` row must credit upstream `pass` tag `{tag}` \ + (build/groups.rs PASS_GROUPS)" ); } } From aaad329aa3da16872fe730ba74b27aef1930b4f3 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 09:17:33 +0000 Subject: [PATCH 08/16] docs(crate): ENG-4683 restore README review fixes lost to a stray checkout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The peer-review pass edited README.md (build-cost accuracy, crates.io availability note, Performance feature caveats) and re-spliced the axum fence, but a `git checkout README.md` used to revert a deliberate test-discrimination check also discarded those uncommitted edits, and the previous commit captured the reverted file. readme_axum_fence_matches_example_file caught it in the regression run — which is the drift guard doing exactly its job. Signed-off-by: Jimbo Freedman --- README.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 54209e6..999e9bf 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,7 @@ Maritime sea-routing primitives on Eurostat MARNET data, in safe Rust. two `(lat, lng)` points and it returns the shortest sea path between them, optionally routing around named chokepoints like the Suez Canal or the Strait of Malacca. The graph data ships inside the crate, so there -is nothing to download, no service to call, and no build step of your -own. +is nothing to download and no service to call. > **Status: pre-1.0.** The API can break between minor versions, and > `rustyroute` is not yet published to crates.io — the crates.io and @@ -28,9 +27,16 @@ own. cargo add rustyroute ``` -That is the whole setup. The default features bake the 50 km graph into -your binary, so the snippet below runs as-is — no environment variable, -no data directory, no build script. +(Not on crates.io yet — until the first release, depend on it by git or +path.) + +That is the whole setup on your side. The default features bake the +50 km graph into your binary, so the snippet below runs as-is — no +environment variable, no data directory, nothing to configure. Note that +rustyroute's *own* first build is slow: its `build.rs` compiles a +bundled SQLite and parses ~17 MiB of GeoPackages into the five graph +archives. That cost is paid once, at build time; every run afterwards +just memory-maps the result. ```rust use rustyroute::Graph; @@ -156,7 +162,10 @@ async fn route(Query(q): Query) -> Response { "features": [{ "type": "Feature", "geometry": { "type": "LineString", "coordinates": coordinates }, - "properties": { "distance_km": r.distance_km, "resolution": 50 }, + "properties": { + "distance_km": r.distance_km, + "resolution": graph.resolution_km(), + }, }], })) .into_response() @@ -386,6 +395,9 @@ Measured on a Linux host with rustc 1.97, release profile: | `route()` — 50 km, Marseille → Shanghai (106 points) | 5.4 ms | | `route()` — 5 km, same pair (204 points) | 29 ms | +The `from_bytes` and 50 km rows need `data-50km` (on by default); the +5 km row needs `data-5km`, which is not. + Loading is effectively free: the archive is mmapped or already resident in the binary, and rkyv reads it in place with no deserialisation. The cost that matters is `route()` itself, currently dominated by a From 7feb1551e3af65bdfedb3e7f6e0c354fb9c16854 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 09:26:46 +0000 Subject: [PATCH 09/16] test(crate): ENG-4683 end-to-end tests for the axum example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC1 ("boots; curl returns valid GeoJSON in [lng, lat] order") was the only acceptance criterion with no automated coverage — it was verified by hand during dev and would have rotted on the next handler edit. readme_contract.rs proves the README and the example agree textually; this proves the example actually works. tests/axum_example_e2e.rs spawns the compiled example as a real process and drives it over a real TCP socket with hand-written HTTP/1.1 — no HTTP-client dependency added, since deny.toml gates every new crate. Four tests: coordinate order, RFC 7946 self-route validity, blocked-edge plumbing, and the 400 error contract. The example gains PORT (default 3000) so parallel tests bind OS-assigned ports instead of racing for one; the README fence was re-spliced to match. example_binary() checks the binary's mtime against the source and rebuilds when stale — without that, a filtered `cargo test --test` run picks up a pre-PORT binary and three of four tests lose a race for port 3000, which is exactly how this file failed on its first run. Also gitignore tests/downstream_consumer/target/: the root /target/ entry is anchored, so reproducing AC3 locally leaves ~300 MB untracked. Signed-off-by: Jimbo Freedman --- .gitignore | 7 + README.md | 9 +- examples/axum_server.rs | 9 +- tests/axum_example_e2e.rs | 326 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 tests/axum_example_e2e.rs diff --git a/.gitignore b/.gitignore index 52231dd..484e7c0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,13 @@ # The downstream-consumer test sub-package is a library crate too; same # rationale for excluding its lockfile. tests/downstream_consumer/Cargo.lock +# `/target/` above is root-anchored, so it does not cover the nested +# sub-package. Running the sub-package's suite directly — e.g. +# `cargo test --manifest-path tests/downstream_consumer/Cargo.toml`, +# which is how you reproduce ENG-4683's AC3 locally — drops ~300 MB of +# build output here. tests/downstream_consumer_smoke.rs itself redirects +# to a target dir under $OUT_DIR and never creates this. +tests/downstream_consumer/target/ # macOS — https://github.com/github/gitignore/blob/main/Global/macOS.gitignore .DS_Store diff --git a/README.md b/README.md index 999e9bf..d24ac45 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,14 @@ fn bad_request(msg: &str) -> Response { #[tokio::main] async fn main() { let app = Router::new().route("/route", get(route)); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + // `PORT=0` asks the OS for a free port — that is how + // tests/axum_example_e2e.rs boots this example without colliding + // with anything already on 3000. The line below prints whichever + // port was actually bound. + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .unwrap(); println!("listening on http://{}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } diff --git a/examples/axum_server.rs b/examples/axum_server.rs index 838caba..cd91e73 100644 --- a/examples/axum_server.rs +++ b/examples/axum_server.rs @@ -109,7 +109,14 @@ fn bad_request(msg: &str) -> Response { #[tokio::main] async fn main() { let app = Router::new().route("/route", get(route)); - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + // `PORT=0` asks the OS for a free port — that is how + // tests/axum_example_e2e.rs boots this example without colliding + // with anything already on 3000. The line below prints whichever + // port was actually bound. + let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + .await + .unwrap(); println!("listening on http://{}", listener.local_addr().unwrap()); axum::serve(listener, app).await.unwrap(); } diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs new file mode 100644 index 0000000..e21ab54 --- /dev/null +++ b/tests/axum_example_e2e.rs @@ -0,0 +1,326 @@ +//! ENG-4683 AC1, end to end: boot `examples/axum_server` as a real +//! process and drive it over a real socket. +//! +//! The ticket's first acceptance criterion is a runtime one — +//! "`cargo run --example axum_server` boots; `curl …` returns valid +//! GeoJSON with coordinates in `[lng, lat]` order" — and until this file +//! existed it was only ever checked by hand. `tests/readme_contract.rs` +//! proves the README and the example agree *textually*; this proves the +//! example actually works. +//! +//! What each test guards: +//! AC1 coordinate order -> `route_returns_geojson_in_lng_lat_order` +//! AC1 valid GeoJSON -> `self_route_still_emits_a_valid_linestring` +//! blocked-edge plumbing -> `blocking_suez_lengthens_the_route` +//! error contract -> `bad_input_is_rejected_with_400` +//! +//! Deliberately no HTTP client dependency: a hand-written GET over +//! `TcpStream` keeps this test free of reqwest/hyper and of any version +//! coupling to axum's own stack. The crate already treats extra +//! dependencies as a cost (see `deny.toml`'s licence gate and the wasm +//! job's dependency reasoning in `.github/workflows/ci.yaml`). +//! +//! Gated off wasm32 because it spawns a process and opens sockets; +//! `tests/golden_routes.rs` carries the same gate for the same reason. +#![cfg(not(target_arch = "wasm32"))] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; + +/// Owns the spawned server so it is killed even if a test panics. +struct Server { + child: Child, + port: u16, +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +/// Locate the compiled example. +/// +/// `cargo test` (unfiltered) builds examples alongside test targets, so +/// under a normal run — and in CI, which runs +/// `cargo test --all-features` — the binary is already this test +/// binary's sibling in `target//examples/` and freshly built. +/// +/// Two cases have to be handled or this test becomes flaky rather than +/// merely red: +/// +/// 1. **Absent.** `cargo test --test axum_example_e2e` may not build +/// examples at all. +/// 2. **Stale.** Worse, a *previously* built binary can still be +/// sitting there after `examples/axum_server.rs` was edited. That is +/// not a hypothetical: the first run of this file picked up a +/// pre-`PORT` binary, so all four tests fought over the hardcoded +/// port 3000 and three died with an empty stdout. A stale binary +/// must never be silently trusted. +/// +/// So the sibling is used only when it is newer than the example +/// source; otherwise we rebuild. The rebuild targets a dedicated +/// directory because the outer `cargo test` holds the lock on the main +/// `target/` for the duration of the run — the same reason +/// `tests/feature_matrix.rs` and `tests/downstream_consumer_smoke.rs` +/// use their own target dirs. +fn example_binary() -> PathBuf { + let name = if cfg!(windows) { + "axum_server.exe" + } else { + "axum_server" + }; + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let source = manifest.join("examples").join("axum_server.rs"); + + // current_exe is target//deps/-; the examples + // directory is its sibling one level up. + let mut dir = std::env::current_exe().expect("current_exe"); + dir.pop(); + if dir.ends_with("deps") { + dir.pop(); + } + let candidate = dir.join("examples").join(name); + if is_fresh(&candidate, &source) { + return candidate; + } + + let target_dir = std::env::var("OUT_DIR") + .map(|s| PathBuf::from(s).join("axum_example_e2e_target")) + .unwrap_or_else(|_| std::env::temp_dir().join("rustyroute_axum_example_e2e_target")); + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + let status = Command::new(&cargo) + .arg("build") + .arg("--example") + .arg("axum_server") + .arg("--manifest-path") + .arg(manifest.join("Cargo.toml")) + .arg("--target-dir") + .arg(&target_dir) + .env("CARGO_TERM_COLOR", "never") + .status() + .expect("spawn cargo build --example axum_server"); + assert!(status.success(), "failed to build the axum_server example"); + + let built = target_dir.join("debug").join("examples").join(name); + assert!( + built.exists(), + "example binary still missing after build: {}", + built.display() + ); + built +} + +/// `true` when `bin` exists and is at least as new as `source`. +fn is_fresh(bin: &PathBuf, source: &PathBuf) -> bool { + let modified = |p: &PathBuf| std::fs::metadata(p).and_then(|m| m.modified()).ok(); + match (modified(bin), modified(source)) { + (Some(b), Some(s)) => b >= s, + // If either timestamp is unavailable, rebuild rather than guess. + _ => false, + } +} + +/// Boot the example on an OS-assigned port and wait until it reports +/// the port it bound. No sleep-and-hope: the readiness signal is the +/// server's own stdout line. +fn start_server() -> Server { + let mut child = Command::new(example_binary()) + .env("PORT", "0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn axum_server example"); + + let stdout = child.stdout.take().expect("piped stdout"); + let mut line = String::new(); + BufReader::new(stdout) + .read_line(&mut line) + .expect("read the server's listening line"); + + // "listening on http://0.0.0.0:34567" + let port: u16 = line + .rsplit(':') + .next() + .unwrap_or_default() + .trim() + .parse() + .unwrap_or_else(|e| panic!("could not parse a port out of {line:?}: {e}")); + + Server { child, port } +} + +/// Minimal HTTP/1.1 GET. Returns `(status_code, body)`. +fn get(port: u16, path_and_query: &str) -> (u16, String) { + let mut stream = + TcpStream::connect(("127.0.0.1", port)).expect("connect to the example server"); + write!( + stream, + "GET {path_and_query} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .expect("write request"); + stream.flush().expect("flush request"); + + let mut raw = String::new(); + stream.read_to_string(&mut raw).expect("read response"); + + let (head, body) = raw + .split_once("\r\n\r\n") + .unwrap_or_else(|| panic!("malformed HTTP response: {raw:?}")); + let status: u16 = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|c| c.parse().ok()) + .unwrap_or_else(|| panic!("no status code in {head:?}")); + (status, body.to_string()) +} + +fn coordinates(body: &str) -> Vec> { + let v: serde_json::Value = serde_json::from_str(body).expect("response is JSON"); + assert_eq!(v["type"], "FeatureCollection", "body was: {body}"); + let feature = &v["features"][0]; + assert_eq!(feature["geometry"]["type"], "LineString"); + serde_json::from_value(feature["geometry"]["coordinates"].clone()).expect("coordinates array") +} + +fn distance_km(body: &str) -> f64 { + let v: serde_json::Value = serde_json::from_str(body).expect("response is JSON"); + v["features"][0]["properties"]["distance_km"] + .as_f64() + .unwrap_or_else(|| panic!("no numeric distance_km in {body}")) +} + +/// AC1. The whole point of the example: `Route.coordinates` is +/// `(lat, lng)` (src/loader.rs:41-42) and GeoJSON positions are +/// `[lng, lat]`, so the response must carry the swap. +/// +/// Marseille (43.30 N, 5.37 E) → Shanghai. The first position must open +/// with the *longitude* ~5.19. If the swap were missing or inverted the +/// first element would be ~43.23, which this asserts against explicitly +/// rather than just checking "two numbers came back". +#[test] +fn route_returns_geojson_in_lng_lat_order() { + let server = start_server(); + let (status, body) = get( + server.port, + "/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47", + ); + assert_eq!(status, 200, "body was: {body}"); + + let coords = coordinates(&body); + assert!( + coords.len() > 2, + "expected a multi-point path, got {coords:?}" + ); + + let first = &coords[0]; + assert_eq!(first.len(), 2, "a GeoJSON position is [lng, lat]"); + let (lng, lat) = (first[0], first[1]); + assert!( + (4.0..7.0).contains(&lng), + "first element must be the LONGITUDE near Marseille (~5.19), got {lng} \ + — the (lat, lng) -> [lng, lat] swap is missing or inverted" + ); + assert!( + (42.0..45.0).contains(&lat), + "second element must be the LATITUDE near Marseille (~43.23), got {lat}" + ); + + // Every position must be a well-formed [lng, lat] pair in range. + for p in &coords { + assert_eq!(p.len(), 2, "malformed position {p:?}"); + assert!( + (-180.0..=180.0).contains(&p[0]), + "longitude out of range: {p:?}" + ); + assert!( + (-90.0..=90.0).contains(&p[1]), + "latitude out of range: {p:?}" + ); + } + + assert!( + distance_km(&body) > 10_000.0, + "Marseille -> Shanghai is a long way; got {} km", + distance_km(&body) + ); +} + +/// AC1, the "valid GeoJSON" half. `Graph::route` returns exactly ONE +/// coordinate when both endpoints snap to the same node +/// (src/loader.rs:442-449, pinned by tests/route_smoke.rs:28-38), but +/// RFC 7946 §3.1.4 requires a LineString to have two or more positions. +/// Without the example's pad this response would be invalid GeoJSON. +#[test] +fn self_route_still_emits_a_valid_linestring() { + let server = start_server(); + let (status, body) = get( + server.port, + "/route?fromLatLng=43.30,5.37&toLatLng=43.30,5.37", + ); + assert_eq!(status, 200, "body was: {body}"); + + let coords = coordinates(&body); + assert!( + coords.len() >= 2, + "RFC 7946 3.1.4: a LineString needs >= 2 positions, got {}: {coords:?}", + coords.len() + ); + assert_eq!( + coords[0], coords[1], + "a degenerate self-route repeats its single point" + ); + assert_eq!(distance_km(&body), 0.0, "a self-route covers no distance"); +} + +/// The `block=` parameter must actually reach +/// `Graph::edges_for_groups` + `Graph::route`. Blocking the Suez Canal +/// forces the route around the Cape of Good Hope, so the distance must +/// grow — an inequality, so the test does not pin a golden number that +/// `tests/golden_routes.rs` already owns. +#[test] +fn blocking_suez_lengthens_the_route() { + let server = start_server(); + let q = "/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47"; + let (open_status, open_body) = get(server.port, q); + let (blocked_status, blocked_body) = get(server.port, &format!("{q}&block=suezCanal")); + assert_eq!(open_status, 200); + assert_eq!(blocked_status, 200, "body was: {blocked_body}"); + + assert!( + distance_km(&blocked_body) > distance_km(&open_body), + "blocking suezCanal ({} km) must exceed the open route ({} km)", + distance_km(&blocked_body), + distance_km(&open_body) + ); +} + +/// The error contract the example documents: unparseable coordinates +/// and unknown edge groups are client errors, not 500s or panics. The +/// second case also proves an unknown group is rejected rather than +/// silently ignored. +#[test] +fn bad_input_is_rejected_with_400() { + let server = start_server(); + + let (status, body) = get(server.port, "/route?fromLatLng=abc&toLatLng=0,0"); + assert_eq!(status, 400, "body was: {body}"); + assert!( + body.contains("fromLatLng"), + "the error should name the offending parameter, got: {body}" + ); + + let (status, body) = get( + server.port, + "/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=notAGroup", + ); + assert_eq!(status, 400, "body was: {body}"); + assert!( + body.contains("notAGroup"), + "the error should name the unknown group, got: {body}" + ); +} From 45daf96f51c78c50722f7ac4a4461b4fedf984ac Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 09:45:53 +0000 Subject: [PATCH 10/16] =?UTF-8?q?fix(crate):=20ENG-4683=20address=20review?= =?UTF-8?q?=20=E2=80=94=20close=20the=20doctest=20bypass,=20harden=20the?= =?UTF-8?q?=20E2E=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2. Retagging the README's library quickstart ```rust,ignore silently disabled AC2: the quickstart stopped being compiled AND run, yet all six drift tests stayed green and CI passed. Verified by doing it — the doctest went from `1 passed; 1 ignored` to `0 passed; 2 ignored`. spec.md section 5 names this as the first forbidden shortcut and plan.md GC16 pinned it by hand, but nothing enforced it. Added readme_rust_fences_are_exactly_the_two_expected, which requires the Rust-family info strings to be exactly ["rust", "rust,no_run"] in order. P3s: - examples: replace graph.resolution_km() with const RESOLUTION_KM. The accessor returns 0 for a Graph::from_bytes handle — the very substitution this file's doc comment recommends for wasm — so a reader following that advice would have served "resolution": 0. - readme_contract: the pass-tag check was a whole-file contains(), so swapping two rows' Source cells passed. Now parses (group, source) rows and checks the pairing. - README: the sample response showed 16354.073, the CLI's {:.3} format; the example serialises a raw f64. Now shows what it actually emits. - e2e: is_fresh only compared against examples/axum_server.rs, so a binary stale w.r.t. src/ was trusted. Now takes the newest mtime across src/, examples/ and Cargo.toml. - e2e: build the Server guard immediately after spawn — Child does not kill on drop, and two panic sites sat before the guard existed. - e2e: stderr is inherited, not piped-and-never-drained; a child panic now reaches the test output instead of filling a pipe. - e2e: memoise example_binary() behind OnceLock so four tests do not each spawn a cargo build against one target dir. Signed-off-by: Jimbo Freedman --- README.md | 18 ++++++-- examples/axum_server.rs | 16 +++++-- tests/axum_example_e2e.rs | 82 +++++++++++++++++++++++++++------- tests/readme_contract.rs | 93 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 183 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d24ac45..7dac82d 100644 --- a/README.md +++ b/README.md @@ -93,19 +93,29 @@ use axum::{Json, Router}; use rustyroute::{EdgeId, Graph, RouteError}; use serde_json::json; +/// Which pre-baked grid to serve. Used for both the load and the +/// reported `resolution`, so the two cannot disagree. (Don't reach for +/// `Graph::resolution_km()` here: it returns 0 for a handle built with +/// `Graph::from_bytes`, which is exactly the substitution suggested +/// below.) +const RESOLUTION_KM: u32 = 50; + /// Load the graph once and leak it for the process lifetime — the /// long-lived-handle pattern documented on `rustyroute::Graph`. `Graph` /// is `Send + Sync` but not `Clone`, so a handler shared across tokio /// worker threads needs `&'static Graph` (or an `Arc`). /// -/// `Graph::load(50)` needs no setup on default features: it falls back +/// `Graph::load` needs no setup on default features: it falls back /// to the `data-50km` slice baked into the binary. Without a filesystem /// (wasm, scratch containers) use that slice directly instead — /// `Graph::from_bytes(rustyroute::data::BYTES_50KM)` — which requires /// the `data-50km` feature to be enabled. fn graph() -> &'static Graph { static G: OnceLock<&'static Graph> = OnceLock::new(); - G.get_or_init(|| Box::leak(Box::new(Graph::load(50).expect("load 50km graph")))) + G.get_or_init(|| { + let g = Graph::load(RESOLUTION_KM).expect("load the graph"); + Box::leak(Box::new(g)) + }) } /// `?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=suezCanal` @@ -164,7 +174,7 @@ async fn route(Query(q): Query) -> Response { "geometry": { "type": "LineString", "coordinates": coordinates }, "properties": { "distance_km": r.distance_km, - "resolution": graph.resolution_km(), + "resolution": RESOLUTION_KM, }, }], })) @@ -212,7 +222,7 @@ curl "localhost:3000/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47" "type": "LineString", "coordinates": [[5.1927490234375, 43.230220794677734], "..."] }, - "properties": { "distance_km": 16354.073, "resolution": 50 } + "properties": { "distance_km": 16354.07348421216, "resolution": 50 } } ] } diff --git a/examples/axum_server.rs b/examples/axum_server.rs index cd91e73..ded3740 100644 --- a/examples/axum_server.rs +++ b/examples/axum_server.rs @@ -20,19 +20,29 @@ use axum::{Json, Router}; use rustyroute::{EdgeId, Graph, RouteError}; use serde_json::json; +/// Which pre-baked grid to serve. Used for both the load and the +/// reported `resolution`, so the two cannot disagree. (Don't reach for +/// `Graph::resolution_km()` here: it returns 0 for a handle built with +/// `Graph::from_bytes`, which is exactly the substitution suggested +/// below.) +const RESOLUTION_KM: u32 = 50; + /// Load the graph once and leak it for the process lifetime — the /// long-lived-handle pattern documented on `rustyroute::Graph`. `Graph` /// is `Send + Sync` but not `Clone`, so a handler shared across tokio /// worker threads needs `&'static Graph` (or an `Arc`). /// -/// `Graph::load(50)` needs no setup on default features: it falls back +/// `Graph::load` needs no setup on default features: it falls back /// to the `data-50km` slice baked into the binary. Without a filesystem /// (wasm, scratch containers) use that slice directly instead — /// `Graph::from_bytes(rustyroute::data::BYTES_50KM)` — which requires /// the `data-50km` feature to be enabled. fn graph() -> &'static Graph { static G: OnceLock<&'static Graph> = OnceLock::new(); - G.get_or_init(|| Box::leak(Box::new(Graph::load(50).expect("load 50km graph")))) + G.get_or_init(|| { + let g = Graph::load(RESOLUTION_KM).expect("load the graph"); + Box::leak(Box::new(g)) + }) } /// `?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=suezCanal` @@ -91,7 +101,7 @@ async fn route(Query(q): Query) -> Response { "geometry": { "type": "LineString", "coordinates": coordinates }, "properties": { "distance_km": r.distance_km, - "resolution": graph.resolution_km(), + "resolution": RESOLUTION_KM, }, }], })) diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs index e21ab54..920ea5f 100644 --- a/tests/axum_example_e2e.rs +++ b/tests/axum_example_e2e.rs @@ -26,8 +26,9 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::OnceLock; /// Owns the spawned server so it is killed even if a test panics. struct Server { @@ -68,13 +69,23 @@ impl Drop for Server { /// `tests/feature_matrix.rs` and `tests/downstream_consumer_smoke.rs` /// use their own target dirs. fn example_binary() -> PathBuf { + // Memoised: all four tests call this, and on the rebuild path each + // would otherwise spawn its own `cargo build` against the same + // --target-dir. Cargo's file lock makes the losers block rather + // than corrupt anything, so it is wasted wall-clock — but + // tests/feature_matrix.rs:36-41 already settled this shape with a + // mutex, and memoising fixes the redundant work too. + static BIN: OnceLock = OnceLock::new(); + BIN.get_or_init(build_or_locate_example).clone() +} + +fn build_or_locate_example() -> PathBuf { let name = if cfg!(windows) { "axum_server.exe" } else { "axum_server" }; let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let source = manifest.join("examples").join("axum_server.rs"); // current_exe is target//deps/-; the examples // directory is its sibling one level up. @@ -84,7 +95,7 @@ fn example_binary() -> PathBuf { dir.pop(); } let candidate = dir.join("examples").join(name); - if is_fresh(&candidate, &source) { + if is_fresh(&candidate, &manifest) { return candidate; } @@ -114,35 +125,76 @@ fn example_binary() -> PathBuf { built } -/// `true` when `bin` exists and is at least as new as `source`. -fn is_fresh(bin: &PathBuf, source: &PathBuf) -> bool { - let modified = |p: &PathBuf| std::fs::metadata(p).and_then(|m| m.modified()).ok(); - match (modified(bin), modified(source)) { - (Some(b), Some(s)) => b >= s, - // If either timestamp is unavailable, rebuild rather than guess. - _ => false, +/// `true` when `bin` exists and is at least as new as every input that +/// can change its behaviour. +/// +/// Not just `examples/axum_server.rs`: the example links the library, so +/// editing `src/loader.rs` — say, changing the `start == goal` +/// self-route branch — and then running the filtered test would +/// otherwise exercise a binary built before the change and report a +/// pass for code that no longer exists. +fn is_fresh(bin: &Path, manifest: &Path) -> bool { + let Ok(bin_time) = std::fs::metadata(bin).and_then(|m| m.modified()) else { + return false; // missing, or no timestamp — rebuild rather than guess + }; + + let mut newest = None; + let mut stack = vec![manifest.join("src"), manifest.join("examples")]; + let mut files = vec![manifest.join("Cargo.toml")]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + return false; // cannot enumerate an input — rebuild + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else { + files.push(path); + } + } + } + for f in files { + match std::fs::metadata(&f).and_then(|m| m.modified()) { + Ok(t) => newest = Some(newest.map_or(t, |n: std::time::SystemTime| n.max(t))), + Err(_) => return false, + } } + + newest.is_some_and(|n| bin_time >= n) } /// Boot the example on an OS-assigned port and wait until it reports /// the port it bound. No sleep-and-hope: the readiness signal is the /// server's own stdout line. fn start_server() -> Server { - let mut child = Command::new(example_binary()) + let child = Command::new(example_binary()) .env("PORT", "0") .stdout(Stdio::piped()) - .stderr(Stdio::piped()) + // Inherited, not piped: an undrained pipe blocks the child once + // it fills, and it is the only unbounded-blocking surface here. + // Inheriting also puts a child panic in the test output, where + // it is actionable — capturing stderr and never showing it is + // what made the first failure of this file hard to diagnose. + .stderr(Stdio::inherit()) .spawn() .expect("spawn axum_server example"); - let stdout = child.stdout.take().expect("piped stdout"); + // Build the guard BEFORE anything that can panic. `Child` does not + // kill on drop, so a panic in the readiness read or the port parse + // would otherwise orphan a live server — holding its port and its + // mmap of $OUT_DIR/data/50km.rkyv, which on Windows breaks later + // cargo steps with `os error 5`. + let mut server = Server { child, port: 0 }; + + let stdout = server.child.stdout.take().expect("piped stdout"); let mut line = String::new(); BufReader::new(stdout) .read_line(&mut line) .expect("read the server's listening line"); // "listening on http://0.0.0.0:34567" - let port: u16 = line + server.port = line .rsplit(':') .next() .unwrap_or_default() @@ -150,7 +202,7 @@ fn start_server() -> Server { .parse() .unwrap_or_else(|e| panic!("could not parse a port out of {line:?}: {e}")); - Server { child, port } + server } /// Minimal HTTP/1.1 GET. Returns `(status_code, body)`. diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs index d11fff0..6b17650 100644 --- a/tests/readme_contract.rs +++ b/tests/readme_contract.rs @@ -42,6 +42,83 @@ fn fenced_block(md: &str, info: &str) -> Option { None } +/// The edge-group table as `(group, source)` pairs, in document order. +/// Located by its header row rather than a line number. +fn edge_group_rows(readme: &str) -> Vec<(String, String)> { + let header = "| Group | Source |"; + let start = readme + .find(header) + .unwrap_or_else(|| panic!("README.md must contain an edge-group table headed `{header}`")); + readme[start..] + .lines() + .skip(2) // header row + `|---|---|` separator + .take_while(|l| l.starts_with('|')) + .map(|l| { + let mut cells = l.split('|').skip(1); + let group = cells + .next() + .unwrap_or_else(|| panic!("malformed table row: {l:?}")) + .trim(); + let source = cells + .next() + .unwrap_or_else(|| panic!("table row missing a Source cell: {l:?}")) + .trim(); + (group.trim_matches('`').to_string(), source.to_string()) + }) + .collect() +} + +/// Every fence's info string, in document order. +fn fence_info_strings(md: &str) -> Vec { + let mut out = Vec::new(); + let mut open = false; + for line in md.lines() { + let Some(info) = line.trim_end().strip_prefix("```") else { + continue; + }; + if open { + open = false; // this is a closing fence + } else { + open = true; + out.push(info.to_string()); + } + } + out +} + +/// AC2's real guard. `lib_rs_compiles_readme_as_doctests` only proves +/// the anchor is present — it says nothing about whether the fence it +/// points at is still *live*. +/// +/// Retagging the library quickstart ```` ```rust,ignore ```` is the +/// first forbidden shortcut the spec names, and it is completely +/// silent: the quickstart stops being compiled and run, every other +/// test in this file still passes, and CI stays green. Verified by +/// doing it — `cargo test --doc` went from `1 passed; 1 ignored` to +/// `0 passed; 2 ignored` with all six sibling tests green. +/// +/// So pin the policy directly: exactly two `rust`-family fences, in +/// this order, with these exact info strings. `no_run` on the axum +/// fence is required (it binds a port); anything weaker than a bare +/// `rust` on the quickstart means AC2 is not actually being enforced. +#[test] +fn readme_rust_fences_are_exactly_the_two_expected() { + let readme = read("README.md"); + let rust_fences: Vec = fence_info_strings(&readme) + .into_iter() + .filter(|i| i == "rust" || i.starts_with("rust,") || i.starts_with("rust ")) + .collect(); + assert_eq!( + rust_fences, + vec!["rust".to_string(), "rust,no_run".to_string()], + "README.md must carry exactly two Rust fences: the library quickstart as a \ + bare ```rust (compiled AND run by `cargo test --doc` — that is AC2), then \ + the axum example as ```rust,no_run (compiled only; it binds a port). \ + Adding `ignore`/`compile_fail`, adding a third Rust fence, or reordering \ + them all silently weaken the doctest gate." + ); +} + /// AC4. Ordered whole-vector equality, deliberately: `EDGE_GROUPS` is /// documented as a *stable order* matching `Graph::groups[i]` /// (build/registry.rs:18-21), so a reordered table is a real defect and @@ -168,13 +245,21 @@ fn readme_edge_group_table_pass_tags_match_pass_groups() { "expected 12 PASS_GROUPS entries, parsed {pairs:?}" ); + // Row-by-row, not a whole-file `contains` sweep: a bare `contains` + // finds every tag *somewhere* and so would pass happily if two rows + // had their Source cells swapped — and the column-1 test above only + // reads group names, so nothing else would catch it either. + let rows = edge_group_rows(&readme); for (tag, public) in pairs { - let row_claim = format!("`pass` tag `{tag}`"); - let alt_claim = format!("upstream `pass` tag `{tag}`"); + let source = rows + .iter() + .find(|(group, _)| *group == public) + .map(|(_, source)| source.as_str()) + .unwrap_or_else(|| panic!("README.md has no edge-group row for `{public}`")); assert!( - readme.contains(&row_claim) || readme.contains(&alt_claim), + source.contains(&format!("`{tag}`")), "README.md's `{public}` row must credit upstream `pass` tag `{tag}` \ - (build/groups.rs PASS_GROUPS)" + (build/groups.rs PASS_GROUPS), but its Source cell reads: {source:?}" ); } } From fc1c30d4e9a7f97c2ecdc1f1e9d0f996a72f9735 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 09:58:19 +0000 Subject: [PATCH 11/16] fix(crate): ENG-4683 close residual gaps found re-reviewing the fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review confirmed all 8 prior findings fixed and raised 3 more, all real: - e2e: is_fresh() covered src/, examples/ and Cargo.toml but not build.rs, build/** or vendor/**, so the exact staleness class the previous round filed was still reachable — editing PASS_GROUPS reruns build.rs for the lib but not for examples, so a filtered run would assert against a binary whose graph still had the old edge groups. Now seeded with the build inputs too. Verified: touching build/groups.rs triggers a rebuild where it previously did not. - readme_contract: the row-wise rewrite in the last commit made the pass-tag assertion strictly weaker on what a row claims — it checked only for the backticked tag, so "bbox around `suez`" passed. Restored the full "`pass` tag `{tag}`" phrase. Verified it now fails. Also make the read_dir entry error handling consistent with the branch above it: an unreadable entry means rebuild, not skip. - readme_contract: module doc still said five claims / six tests and routed AC2 to lib_rs_compiles_readme_as_doctests — the weak check the new fence test exists to backstop. Updated, with a note saying which of the two is load-bearing so nobody deletes the wrong one. Signed-off-by: Jimbo Freedman --- tests/axum_example_e2e.rs | 22 +++++++++++++++++++--- tests/readme_contract.rs | 21 +++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs index 920ea5f..a155e41 100644 --- a/tests/axum_example_e2e.rs +++ b/tests/axum_example_e2e.rs @@ -138,14 +138,30 @@ fn is_fresh(bin: &Path, manifest: &Path) -> bool { return false; // missing, or no timestamp — rebuild rather than guess }; + // Every input that feeds the binary, not just its own source. + // `build.rs` + `build/**` compile `vendor/**`'s GeoPackages into the + // graph archives the example serves, and `build/groups.rs` + // additionally generates `EDGE_GROUPS`, which the `block=` path + // depends on. Omitting them leaves the original staleness hole open: + // editing `PASS_GROUPS` reruns `build.rs` for the lib and the test + // target but does NOT rebuild examples, so a filtered run would + // assert against a binary whose graph still has the old groups. let mut newest = None; - let mut stack = vec![manifest.join("src"), manifest.join("examples")]; - let mut files = vec![manifest.join("Cargo.toml")]; + let mut stack = vec![ + manifest.join("src"), + manifest.join("examples"), + manifest.join("build"), + manifest.join("vendor"), + ]; + let mut files = vec![manifest.join("Cargo.toml"), manifest.join("build.rs")]; while let Some(dir) = stack.pop() { let Ok(entries) = std::fs::read_dir(&dir) else { return false; // cannot enumerate an input — rebuild }; - for entry in entries.flatten() { + for entry in entries { + // Consistent with the branch above: an unreadable entry + // means we cannot prove freshness, so rebuild. + let Ok(entry) = entry else { return false }; let path = entry.path(); if path.is_dir() { stack.push(path); diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs index 6b17650..71a7668 100644 --- a/tests/readme_contract.rs +++ b/tests/readme_contract.rs @@ -1,13 +1,22 @@ //! ENG-4683: pin `README.md` to the code it documents. //! -//! The README makes five claims that can silently rot, plus one -//! meta-claim about its own machinery: +//! The README makes five claims that can silently rot, plus two +//! meta-claims about its own machinery: //! AC4: the 13 edge groups it tables -> `readme_edge_group_table_matches_edge_groups_exactly` //! AC4: the `pass` tags it credits -> `readme_edge_group_table_pass_tags_match_pass_groups` //! AC5: the axum block it shows -> `readme_axum_fence_matches_example_file` //! AC6: the menaiStrait bbox -> `readme_documents_menai_bbox` //! AC8: the section inventory -> `readme_sections_appear_in_ticket_order` -//! AC2: the doctest anchor itself -> `lib_rs_compiles_readme_as_doctests` +//! AC2: the fences are still live -> `readme_rust_fences_are_exactly_the_two_expected` +//! AC2: the doctest anchor exists -> `lib_rs_compiles_readme_as_doctests` +//! +//! Note which of the two AC2 tests is load-bearing. +//! `lib_rs_compiles_readme_as_doctests` only proves the +//! `#[cfg(doctest)]` anchor is present; it says nothing about whether +//! the fences it points at are still compiled. Retagging the quickstart +//! ```` ```rust,ignore ```` passes that test while silently disabling +//! AC2 entirely. `readme_rust_fences_are_exactly_the_two_expected` is +//! the gate that actually catches it — do not weaken or delete it. //! //! `build/groups.rs` is a build-only module and is not linked into this //! test crate, so the bbox check asserts against its source text — the @@ -256,8 +265,12 @@ fn readme_edge_group_table_pass_tags_match_pass_groups() { .find(|(group, _)| *group == public) .map(|(_, source)| source.as_str()) .unwrap_or_else(|| panic!("README.md has no edge-group row for `{public}`")); + // The full phrase, not just the backticked tag: these twelve + // groups are `pass`-tag derived and `menaiStrait` is the only + // bbox one, so a cell reading "bbox around `suez`" would be a + // real misattribution that a bare tag match would wave through. assert!( - source.contains(&format!("`{tag}`")), + source.contains(&format!("`pass` tag `{tag}`")), "README.md's `{public}` row must credit upstream `pass` tag `{tag}` \ (build/groups.rs PASS_GROUPS), but its Source cell reads: {source:?}" ); From 698e845769bd1992a9d2238bc26ce97fa59ed264 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 10:07:07 +0000 Subject: [PATCH 12/16] =?UTF-8?q?fix(crate):=20ENG-4683=20QA=20=E2=80=94?= =?UTF-8?q?=20accept=20blank=20entries=20in=20the=20block=3D=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in exploratory QA: `block=suezCanal,` returned 400 {"error":"unknown edge group: "} — an error naming no group, because the trailing comma produced an empty name. The CLI deliberately does the opposite and its comment names this exact input: dropping empties "covers --block \"\", a trailing comma (--block suezCanal,), and whitespace-only entries — treating those as 'nothing to block' is kinder than reporting an unknown group whose name prints as nothing" (src/bin/rustyroute.rs, run_route). The two surfaces of the crate disagreed, and the HTTP one produced precisely the error the CLI's author had called out. The example now trims and drops empties before edges_for_groups. Regression-tested by blank_block_entries_are_ignored_not_rejected, which asserts each variant routes identically to a bare block=suezCanal rather than merely returning 200. Signed-off-by: Jimbo Freedman --- README.md | 9 ++++++++- examples/axum_server.rs | 9 ++++++++- tests/axum_example_e2e.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7dac82d..09c3e00 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,14 @@ async fn route(Query(q): Query) -> Response { let graph = graph(); let blocked: HashSet = match q.block.as_deref().filter(|s| !s.is_empty()) { None => HashSet::new(), - Some(names) => match graph.edges_for_groups(names.split(',').map(str::trim)) { + // Trim, then drop empties, so `block=suezCanal,` and + // `block=a,%20,b` mean what the caller obviously intended. The + // CLI does the same (src/bin/rustyroute.rs) — without it a + // trailing comma reports `unknown edge group: ` with a blank + // name, which tells the caller nothing. + Some(names) => match graph + .edges_for_groups(names.split(',').map(str::trim).filter(|n| !n.is_empty())) + { Ok(ids) => ids, Err(e) => return bad_request(&e.to_string()), }, diff --git a/examples/axum_server.rs b/examples/axum_server.rs index ded3740..d171cb6 100644 --- a/examples/axum_server.rs +++ b/examples/axum_server.rs @@ -73,7 +73,14 @@ async fn route(Query(q): Query) -> Response { let graph = graph(); let blocked: HashSet = match q.block.as_deref().filter(|s| !s.is_empty()) { None => HashSet::new(), - Some(names) => match graph.edges_for_groups(names.split(',').map(str::trim)) { + // Trim, then drop empties, so `block=suezCanal,` and + // `block=a,%20,b` mean what the caller obviously intended. The + // CLI does the same (src/bin/rustyroute.rs) — without it a + // trailing comma reports `unknown edge group: ` with a blank + // name, which tells the caller nothing. + Some(names) => match graph + .edges_for_groups(names.split(',').map(str::trim).filter(|n| !n.is_empty())) + { Ok(ids) => ids, Err(e) => return bad_request(&e.to_string()), }, diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs index a155e41..01ee364 100644 --- a/tests/axum_example_e2e.rs +++ b/tests/axum_example_e2e.rs @@ -13,6 +13,7 @@ //! AC1 valid GeoJSON -> `self_route_still_emits_a_valid_linestring` //! blocked-edge plumbing -> `blocking_suez_lengthens_the_route` //! error contract -> `bad_input_is_rejected_with_400` +//! blank `block=` entries -> `blank_block_entries_are_ignored_not_rejected` //! //! Deliberately no HTTP client dependency: a hand-written GET over //! `TcpStream` keeps this test free of reqwest/hyper and of any version @@ -392,3 +393,35 @@ fn bad_input_is_rejected_with_400() { "the error should name the unknown group, got: {body}" ); } + +/// A trailing comma or a blank entry in `block=` is natural input, and +/// the CLI deliberately treats it as "nothing extra to block" rather +/// than reporting `unknown edge group: ` with a blank name +/// (`src/bin/rustyroute.rs`, `run_route`). The HTTP surface must agree — +/// found in QA, where `block=suezCanal,` returned exactly that unhelpful +/// 400. +#[test] +fn blank_block_entries_are_ignored_not_rejected() { + let server = start_server(); + let base = "/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47"; + + let (plain, plain_body) = get(server.port, &format!("{base}&block=suezCanal")); + assert_eq!(plain, 200, "body was: {plain_body}"); + + for suffix in [ + "&block=suezCanal,", + "&block=,suezCanal", + "&block=suezCanal,%20", + ] { + let (status, body) = get(server.port, &format!("{base}{suffix}")); + assert_eq!( + status, 200, + "`{suffix}` must be accepted, not reported as a blank unknown group; got: {body}" + ); + assert_eq!( + distance_km(&body), + distance_km(&plain_body), + "`{suffix}` must route identically to a bare `block=suezCanal`" + ); + } +} From 7e305da34a24df09232448ad6735680b62e88b26 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 10:09:40 +0000 Subject: [PATCH 13/16] refactor(crate): ENG-4683 collapse two README table parsers into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readme_edge_group_table_matches_edge_groups_exactly walked the edge-group table inline for column 1, while edge_group_rows() — added in the review round for the row-aware pass-tag check — did the same walk and returned both columns. Two parsers over one table format is what rots: the next person to change the table updates one and leaves the other silently wrong. The test now derives its column-1 vector from edge_group_rows(). 22 lines removed, behaviour identical (both walks trimmed and stripped backticks the same way; edge_group_rows additionally requires a Source cell, which all 13 rows have). Discrimination re-verified after the dedup — a cleanup that quietly made the assertion vacuous would be worse than the duplication it removed. Signed-off-by: Jimbo Freedman --- tests/readme_contract.rs | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs index 71a7668..89638f1 100644 --- a/tests/readme_contract.rs +++ b/tests/readme_contract.rs @@ -135,23 +135,9 @@ fn readme_rust_fences_are_exactly_the_two_expected() { #[test] fn readme_edge_group_table_matches_edge_groups_exactly() { let readme = read("README.md"); - let header = "| Group | Source |"; - let start = readme - .find(header) - .unwrap_or_else(|| panic!("README.md must contain an edge-group table headed `{header}`")); - - let names: Vec = readme[start..] - .lines() - .skip(2) // header row + `|---|---|` separator - .take_while(|l| l.starts_with('|')) - .map(|l| { - let cell = l - .split('|') - .nth(1) - .unwrap_or_else(|| panic!("malformed table row: {l:?}")) - .trim(); - cell.trim_matches('`').to_string() - }) + let names: Vec = edge_group_rows(&readme) + .into_iter() + .map(|(group, _source)| group) .collect(); let expected: Vec = rustyroute::EDGE_GROUPS From a14db49d9b38d8a4dcb4fad536646f8cc06c3ed2 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 10:34:41 +0000 Subject: [PATCH 14/16] fix(crate): ENG-4683 address Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: `cargo add rustyroute` cannot work — the crate is not on crates.io, which the status callout says two paragraphs earlier. Show the git form that works today, and note the plain form for after the first release. Same for the axum dependency block. - examples: bind 127.0.0.1 by default, not 0.0.0.0. This is code people paste from a README; it should not expose a routing service on every interface because someone tried the quickstart. HOST=0.0.0.0 opts in. - examples: RouteError::NoRoute returned bare text while every other failure returned {"error": ...} JSON, forcing clients to branch on status before parsing. All errors now leave by one door. Asserted by no_route_returns_a_json_error_like_every_other_failure. - README/tests: the claim "This prints 16354.1 km over 106 points" was unasserted anywhere. Rather than soften it to a vague approximation, readme_quickstart_output_matches_reality now parses the numbers out of the README and runs the same route to check them. Verified it fails on both a wrong distance and a wrong point count. Also fixes a weakness the heading test exposed in itself: it scanned every line for `#`, so the new TOML comment inside the dependency fence read as an h1 and failed the inventory. Heading extraction is now fence-aware via prose_lines(); re-verified it still catches a renamed heading. Signed-off-by: Jimbo Freedman --- README.md | 33 +++++++++++---- examples/axum_server.rs | 22 +++++++--- tests/axum_example_e2e.rs | 24 +++++++++++ tests/readme_contract.rs | 87 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 150 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 09c3e00..7615328 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,14 @@ is nothing to download and no service to call. ## Quickstart (library) +rustyroute is **not on crates.io yet**, so until the first release +depend on it by git: + ```sh -cargo add rustyroute +cargo add rustyroute --git https://github.com/spotship/rustyroute ``` -(Not on crates.io yet — until the first release, depend on it by git or -path.) +After the first release, plain `cargo add rustyroute` will work. That is the whole setup on your side. The default features bake the 50 km graph into your binary, so the snippet below runs as-is — no @@ -63,7 +65,8 @@ A complete routing service in about fifty lines. Add these dependencies: ```toml [dependencies] -rustyroute = "0.1" +# Until the first crates.io release; afterwards: rustyroute = "0.1" +rustyroute = { git = "https://github.com/spotship/rustyroute" } axum = "0.8" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } serde = { version = "1", features = ["derive"] } @@ -187,24 +190,36 @@ async fn route(Query(q): Query) -> Response { })) .into_response() } - Err(RouteError::NoRoute) => (StatusCode::NOT_FOUND, "no route").into_response(), + // Every error leaves by the same door, so a client can parse one + // shape: `{"error": "..."}` with a meaningful status. + Err(RouteError::NoRoute) => error(StatusCode::NOT_FOUND, "no route"), Err(e) => bad_request(&e.to_string()), } } +fn error(status: StatusCode, msg: &str) -> Response { + (status, Json(json!({ "error": msg }))).into_response() +} + fn bad_request(msg: &str) -> Response { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + error(StatusCode::BAD_REQUEST, msg) } #[tokio::main] async fn main() { let app = Router::new().route("/route", get(route)); - // `PORT=0` asks the OS for a free port — that is how + // Loopback by default: this is example code people paste, and it + // should not expose a routing service on every interface just + // because someone tried the quickstart. Set `HOST=0.0.0.0` when you + // actually want that — in a container, say. + // + // `PORT=0` asks the OS for a free port, which is how // tests/axum_example_e2e.rs boots this example without colliding // with anything already on 3000. The line below prints whichever - // port was actually bound. + // address was actually bound. + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".into()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")) .await .unwrap(); println!("listening on http://{}", listener.local_addr().unwrap()); diff --git a/examples/axum_server.rs b/examples/axum_server.rs index d171cb6..6966235 100644 --- a/examples/axum_server.rs +++ b/examples/axum_server.rs @@ -114,24 +114,36 @@ async fn route(Query(q): Query) -> Response { })) .into_response() } - Err(RouteError::NoRoute) => (StatusCode::NOT_FOUND, "no route").into_response(), + // Every error leaves by the same door, so a client can parse one + // shape: `{"error": "..."}` with a meaningful status. + Err(RouteError::NoRoute) => error(StatusCode::NOT_FOUND, "no route"), Err(e) => bad_request(&e.to_string()), } } +fn error(status: StatusCode, msg: &str) -> Response { + (status, Json(json!({ "error": msg }))).into_response() +} + fn bad_request(msg: &str) -> Response { - (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response() + error(StatusCode::BAD_REQUEST, msg) } #[tokio::main] async fn main() { let app = Router::new().route("/route", get(route)); - // `PORT=0` asks the OS for a free port — that is how + // Loopback by default: this is example code people paste, and it + // should not expose a routing service on every interface just + // because someone tried the quickstart. Set `HOST=0.0.0.0` when you + // actually want that — in a container, say. + // + // `PORT=0` asks the OS for a free port, which is how // tests/axum_example_e2e.rs boots this example without colliding // with anything already on 3000. The line below prints whichever - // port was actually bound. + // address was actually bound. + let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".into()); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) + let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")) .await .unwrap(); println!("listening on http://{}", listener.local_addr().unwrap()); diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs index 01ee364..4c25db0 100644 --- a/tests/axum_example_e2e.rs +++ b/tests/axum_example_e2e.rs @@ -394,6 +394,30 @@ fn bad_input_is_rejected_with_400() { ); } +/// Every error the example produces leaves by the same door: a JSON +/// `{"error": …}` body. A 404 that returned bare text while 400s +/// returned JSON would force clients to branch on status before they +/// can parse — raised in review of this PR. +/// +/// Blocking Suez *and* Gibraltar seals the Mediterranean, so Marseille +/// genuinely cannot reach Shanghai: a real `RouteError::NoRoute`. +#[test] +fn no_route_returns_a_json_error_like_every_other_failure() { + let server = start_server(); + let (status, body) = get( + server.port, + "/route?fromLatLng=43.30,5.37&toLatLng=31.23,121.47&block=suezCanal,gibraltarStrait", + ); + assert_eq!(status, 404, "body was: {body}"); + + let v: serde_json::Value = serde_json::from_str(&body) + .unwrap_or_else(|e| panic!("404 body must be JSON, got {body:?}: {e}")); + assert_eq!( + v["error"], "no route", + "404 must use the same {{\"error\": …}} shape as the 400s; got {body}" + ); +} + /// A trailing comma or a blank entry in `block=` is natural input, and /// the CLI deliberately treats it as "nothing extra to block" rather /// than reporting `unknown edge group: ` with a blank name diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs index 89638f1..d0d26f6 100644 --- a/tests/readme_contract.rs +++ b/tests/readme_contract.rs @@ -95,6 +95,29 @@ fn fence_info_strings(md: &str) -> Vec { out } +/// Lines that are *prose*, i.e. outside every fenced code block. +/// +/// Without this, anything inside a fence that happens to start with `#` +/// reads as a Markdown heading. Not hypothetical: the dependency block +/// carries a TOML comment (`# Until the first crates.io release…`) and +/// the `sh`/`text` fences use `#` too. This is exactly what +/// `readme_sections_appear_in_ticket_order` tripped over the moment +/// that comment was added. +fn prose_lines(md: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut in_fence = false; + for line in md.lines() { + if line.trim_end().starts_with("```") { + in_fence = !in_fence; + continue; + } + if !in_fence { + out.push(line); + } + } + out +} + /// AC2's real guard. `lib_rs_compiles_readme_as_doctests` only proves /// the anchor is present — it says nothing about whether the fence it /// points at is still *live*. @@ -269,8 +292,8 @@ fn readme_edge_group_table_pass_tags_match_pass_groups() { #[test] fn readme_sections_appear_in_ticket_order() { let readme = read("README.md"); - let headings: Vec<&str> = readme - .lines() + let headings: Vec<&str> = prose_lines(&readme) + .into_iter() .filter(|l| l.starts_with("# ") || l.starts_with("## ")) .collect(); let expected = [ @@ -292,6 +315,66 @@ fn readme_sections_appear_in_ticket_order() { ); } +/// The README states the quickstart's exact output — +/// "This prints `16354.1 km over 106 points`" — and nothing asserted it, +/// so a change to the graph data or the cost model would leave the claim +/// silently wrong. Rather than soften the prose to a vague +/// approximation, run the same computation and hold the README to it. +/// +/// The numbers are *parsed out of the README*, not duplicated here: a +/// second hardcoded copy would just be another thing to drift. +/// +/// No `data-*` feature gate, matching `tests/golden_routes.rs`: this +/// calls only `Graph::load`, which resolves through `$OUT_DIR` for every +/// resolution `build.rs` bakes, regardless of which features are on. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn readme_quickstart_output_matches_reality() { + use rustyroute::Graph; + use std::collections::HashSet; + + let readme = read("README.md"); + let claim = readme + .lines() + .find(|l| l.starts_with("This prints `")) + .expect("README.md must state the quickstart's output as ``This prints `…` ``"); + + // "This prints `16354.1 km over 106 points`." + let inner = claim + .split('`') + .nth(1) + .unwrap_or_else(|| panic!("malformed claim line: {claim:?}")); + let mut words = inner.split_whitespace(); + let claimed_km: f64 = words + .next() + .and_then(|w| w.parse().ok()) + .unwrap_or_else(|| panic!("could not read a distance out of {inner:?}")); + let claimed_points: usize = words + .nth(2) // skip "km" and "over" + .and_then(|w| w.parse().ok()) + .unwrap_or_else(|| panic!("could not read a point count out of {inner:?}")); + + // Exactly what the quickstart does. + let graph = Graph::load(50).expect("Graph::load(50)"); + let route = graph + .route((43.30, 5.37), (31.23, 121.47), &HashSet::new()) + .expect("route Marseille -> Shanghai"); + + assert_eq!( + route.coordinates.len(), + claimed_points, + "README says the quickstart prints {claimed_points} points, but it prints {}", + route.coordinates.len() + ); + // The snippet prints `{:.1}`, so compare at that precision. + assert_eq!( + format!("{:.1}", route.distance_km), + format!("{claimed_km:.1}"), + "README says the quickstart prints {claimed_km:.1} km, but it prints {:.1}", + route.distance_km + ); +} + /// Guard the guard: without the anchor, `cargo test --doc` compiles /// none of the README's fences and AC2 silently lapses. #[test] From 5cc7f0a4a52a36d8d4b4f7e71cdbdf9cc4634eec Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Mon, 27 Jul 2026 10:43:02 +0000 Subject: [PATCH 15/16] docs(crate): ENG-4683 fix stale bind-address comment in the e2e test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment showed the listening line as `http://0.0.0.0:34567`, which stopped being true when the example switched to loopback-by-default in a14db49. Misleading exactly when it matters — while debugging a failed port parse. Caught by Copilot; my own change should have updated it. Signed-off-by: Jimbo Freedman --- tests/axum_example_e2e.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs index 4c25db0..ad4cee7 100644 --- a/tests/axum_example_e2e.rs +++ b/tests/axum_example_e2e.rs @@ -210,7 +210,8 @@ fn start_server() -> Server { .read_line(&mut line) .expect("read the server's listening line"); - // "listening on http://0.0.0.0:34567" + // "listening on http://127.0.0.1:34567" — the example binds loopback + // unless HOST is set, and this test does not set it. server.port = line .rsplit(':') .next() From fe2f4f9068b2764c8d181ee0d028f3815d17dce9 Mon Sep 17 00:00:00 2001 From: Jimbo Freedman Date: Tue, 28 Jul 2026 02:12:42 +0000 Subject: [PATCH 16/16] docs(crate): ENG-4683 add CHANGELOG entry for the README + axum quickstart The Unreleased section already records ENG-4690 and ENG-4692 but had no line for this change. Adds one covering the README rewrite, the runnable axum example, and the drift tests that pin both to the API. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Jimbo Freedman --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e06fbc..a8f845a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `cargo codspeed`). `[skip-perf]` in a PR title bypasses the perf job. Uploads authenticate to CodSpeed over OpenID Connect (`id-token: write`) via the org's CodSpeed GitHub App — no `CODSPEED_TOKEN` secret required. +- Onboarding documentation (ENG-4683): a full `README.md` with a + copy-pasteable library quickstart, a 50-line axum HTTP server + quickstart, the build-pipeline diagram, the `data-{N}km` feature + matrix, and the 13 edge groups. The axum snippet ships as a runnable + `examples/axum_server.rs`, and both quickstarts work on default + features (`data-50km`) with no data setup. Drift is pinned by tests: + README fences compile under `cargo test --doc`, the edge-group table + is compared against `EDGE_GROUPS`, and an end-to-end test boots the + example and asserts GeoJSON `[lng, lat]` output. - Automated release pipeline (ENG-4692): `release-plz` opens a `chore: release vX.Y.Z` PR from conventional commits and, on merge, publishes to crates.io and creates the GitHub release/tag;