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/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; 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/README.md b/README.md index 5b25587..7615328 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,261 @@ # 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 and no service to call. -> **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 +## Quickstart (library) -Not yet published to crates.io. Once published: +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 ``` -## Usage +After the first release, plain `cargo add rustyroute` will work. -Load a graph and compute a route. Coordinates are `(lat, lng)` in -decimal degrees: +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; 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] +# 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"] } +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; + +/// 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` 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(|| { + 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` +#[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(), + // 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()), + }, + }; + + 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": RESOLUTION_KM, + }, + }], + })) + .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 { + error(StatusCode::BAD_REQUEST, msg) +} + +#[tokio::main] +async fn main() { + let app = Router::new().route("/route", get(route)); + // 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 + // 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!("{host}:{port}")) + .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.07348421216, "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 +291,182 @@ 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 | + +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 +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`). diff --git a/examples/axum_server.rs b/examples/axum_server.rs new file mode 100644 index 0000000..6966235 --- /dev/null +++ b/examples/axum_server.rs @@ -0,0 +1,151 @@ +//! 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; + +/// 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` 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(|| { + 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` +#[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(), + // 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()), + }, + }; + + 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": RESOLUTION_KM, + }, + }], + })) + .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 { + error(StatusCode::BAD_REQUEST, msg) +} + +#[tokio::main] +async fn main() { + let app = Router::new().route("/route", get(route)); + // 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 + // 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!("{host}:{port}")) + .await + .unwrap(); + println!("listening on http://{}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.unwrap(); +} 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; diff --git a/tests/axum_example_e2e.rs b/tests/axum_example_e2e.rs new file mode 100644 index 0000000..ad4cee7 --- /dev/null +++ b/tests/axum_example_e2e.rs @@ -0,0 +1,452 @@ +//! 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` +//! 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 +//! 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::{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 { + 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 { + // 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")); + + // 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, &manifest) { + 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 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 + }; + + // 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"), + 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 { + // 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); + } 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 child = Command::new(example_binary()) + .env("PORT", "0") + .stdout(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"); + + // 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://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() + .unwrap_or_default() + .trim() + .parse() + .unwrap_or_else(|e| panic!("could not parse a port out of {line:?}: {e}")); + + server +} + +/// 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}" + ); +} + +/// 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 +/// (`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`" + ); + } +} 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:?}" + ); + } +} 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" + ); +} diff --git a/tests/readme_contract.rs b/tests/readme_contract.rs new file mode 100644 index 0000000..d0d26f6 --- /dev/null +++ b/tests/readme_contract.rs @@ -0,0 +1,389 @@ +//! ENG-4683: pin `README.md` to the code it documents. +//! +//! 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 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 +//! 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 +} + +/// 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 +} + +/// 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*. +/// +/// 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 +/// a set/`contains` assertion would wave it through. +#[test] +fn readme_edge_group_table_matches_edge_groups_exactly() { + let readme = read("README.md"); + let names: Vec = edge_group_rows(&readme) + .into_iter() + .map(|(group, _source)| group) + .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. +/// +/// 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"); + + 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:?}" + ); + + // 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 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}`")); + // 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!("`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:?}" + ); + } +} + +/// 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> = prose_lines(&readme) + .into_iter() + .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" + ); +} + +/// 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] +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)." + ); +}