Files
settled-reach/server/src/atlas/browse_proxy.rs
T
jpmschweitzerandClaude Fable 5 d48d72fd31 feat(engine): T-1131 browse data proxy — six entity kinds, index+detail, one wire envelope (D-254 SS4)
browse_reader.rs: BrowseReader on the CityContextReader::open() pattern
— six index + six detail reads against systems.db (bodies filterable by
containing system; system/corporation/commodity details fold their join
partners). browse_proxy.rs: BrowseRequest{browse, kind, query} /
BrowseResponse{kind, status, index, detail} wire types + dispatcher;
BrowseIndexRow{id, primary, secondary} generic across kinds;
BrowseDetail a per-kind enum of field-exhaustive structs.

Demux: Inbound::BrowseRequest is the FIFTH map shape — deliberately the
last; the doc's four-shape ceiling is re-pinned at five with rationale
(six kinds x two forms folded into ONE envelope whose internal enums
pick sub-behavior, the AtlasLayerRequest.up_to precedent, instead of
twelve top-level shapes) and a hard rule that a sixth shape must
migrate to the D-225 tagged-envelope framing. Served for BOTH roles,
connection-tagged 1:1 in-order like atlas/starmap/citynames.
serve_browse_requests pub so integration tests drive the true
end-to-end pipeline. Wire-only per D-254 (T-949 precedent); v1
exclusions (cascade geometry, event logs) respected.

30 unit tests + 7 bridge_tcp integration tests (six-kind round-trip
over real TCP, reader-can-browse, no crossed responses between two
readers, unknown-id NotFound, empty-table Ready); 2 pre-existing tests
updated for the new receive_bridge_inputs parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 10:01:21 +02:00

1049 lines
42 KiB
Rust

//! Data browser wire proxy (D-254 §4, T-1131) — one demux shape serving all
//! six v1 browse entity kinds (star systems, bodies, stations, corporations,
//! commodities, trait templates).
//!
//! **Why one envelope, not six/twelve request types.** `crate::bridge`'s
//! inbound demux (`decode_inbound`) is documented as topping out at four
//! hand-sniffed frame shapes ("four shapes is the practical limit of this
//! hand-rolled sniffing... do not add a fifth probe"). Six entity kinds x
//! two forms (index/detail) would be up to twelve new top-level shapes —
//! far past that ceiling. Instead this module follows the SAME pattern
//! `AtlasLayerRequest{body_id, up_to: CascadeLayer}` already established:
//! one wire message, an internal enum (here, two: [`BrowseEntityKind`] and
//! [`BrowseQuery`]) picks the sub-behavior. `decode_inbound` gains exactly
//! one new probe (`browse: bool`, the same mandatory-discriminator
//! convention as `star_map`/`city_names`) — the fifth and, per that
//! module's own ceiling note, deliberately the last one this hand-rolled
//! scheme should ever carry; a sixth inbound shape must migrate to the
//! tagged-envelope framing D-225 already deferred.
//!
//! **D-254 §4 v1 scope — six registry-tier tables, index + detail per
//! kind:**
//!
//! 1. Star systems (`star_systems` + `system_economy`/`system_factions`/
//! `system_culture` folded into the detail screen)
//! 2. Bodies (`bodies`, index filterable by system)
//! 3. Stations (`stations`)
//! 4. Corporations (`corporations` + `corp_presence`/`corp_financial_state`
//! folded in)
//! 5. Commodities (`commodities` + `production_chains`/`chain_inputs`)
//! 6. Trait catalog (`trait_templates`)
//!
//! Deliberately excluded from v1 (D-254 §4): cascade-derived atlas geometry
//! tables (`atlas_cities`/`atlas_roads`/…, partially populated mid-Phase-4)
//! and event-log tables (`corp_lifecycle_events`/`system_history`/
//! `historical_events`, wrong UI shape for list+detail).
//!
//! **D-010 boundary note.** A Reader connection has no character and
//! receives no `ObserverSnapshot` — there is no per-character fog to bound
//! against here. What a Reader may see is bounded by connection class, and
//! this proxy's six entities are registry-tier (identical across every
//! save, cascade-independent) — the same "install-static/world-public"
//! class `handle_star_map_request`/`handle_city_names_request`/
//! `handle_atlas_request` already serve to any connection. Browsing a
//! specific save's diverged dynamic state is explicitly OUT of this
//! proxy's scope (D-254 §4) — none of the six v1 tables carry that.
use serde::{Deserialize, Serialize};
use crate::atlas::browse_reader::{
BodyDetailRow, BrowseReadError, BrowseReader, CommodityDetailRow, CorporationDetailRow,
StarSystemDetailRow, StationDetailRow, TraitTemplateDetailRow,
};
// ---------------------------------------------------------------------------
// Wire request
// ---------------------------------------------------------------------------
/// The six v1 browse entity kinds (D-254 §4). Unit-variant-only enum — on
/// the wire this is a bare MessagePack string (`"StarSystem"`, `"Body"`, …),
/// same convention as `CascadeLayer` in `AtlasLayerRequest.up_to`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BrowseEntityKind {
StarSystem,
Body,
Station,
Corporation,
Commodity,
TraitTemplate,
}
/// Index-vs-detail request form. Both variants carry fields, so on the wire
/// each is a single-key MessagePack map (`{"Index": {...}}` /
/// `{"Detail": {...}}`), same convention as `AtlasLayerStatus::Error(String)`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BrowseQuery {
/// List form: id + display fields for every row of this kind.
/// `filter_system_id` only means anything for [`BrowseEntityKind::Body`]
/// (D-254 §4: "Bodies, filterable by system") — ignored for every other
/// kind, which always return every row.
Index { filter_system_id: Option<String> },
/// Single full-row form. `id` is the entity's own primary key string
/// (system_id / body_id / station_id / corp_id / commodity_id / trait
/// tag) — the same string an `Index` response's `BrowseIndexRow::id`
/// carries for that row.
Detail { id: String },
}
/// A client request for browse data (T-1131). `browse` is the Inbound
/// discriminator (see `crate::bridge`'s demux doc): always `true`. Its
/// presence, not its value, is what disambiguates this map shape from the
/// other four.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowseRequest {
pub browse: bool,
pub kind: BrowseEntityKind,
pub query: BrowseQuery,
}
// ---------------------------------------------------------------------------
// Wire response
// ---------------------------------------------------------------------------
/// Status of a [`BrowseResponse`]. Same three-way shape (two unit variants +
/// one data variant) as `AtlasLayerStatus`/`StarMapStatus`/`CityNamesStatus`
/// minus `Pending` (nothing here is cascade-generated, so nothing is ever
/// "come back later" — see module doc's D-010 boundary note).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BrowseStatus {
/// The request was served (`index` or `detail` is populated,
/// matching the request's `query` form).
Ready,
/// `Detail`-only: the requested `id` does not exist in that kind's
/// table. `Index` requests never produce `NotFound` — an
/// empty/unfiltered table is `Ready` with an empty `index` list
/// (matches `CityNamesStatus`'s "unknown body -> Ready with empty
/// list" convention, not a distinct not-found case).
NotFound,
/// DB/IO failure (message for the client log).
Error(String),
}
/// Generic index row — the SAME shape for all six kinds, matching D-254
/// §4's index-screen spec ("a scrollable list — name + one or two summary
/// columns"). `id` is the primary key to pass back in a following `Detail`
/// request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowseIndexRow {
pub id: String,
/// Display name (or a synthesized fallback when the row has none
/// authored — see `browse_reader`'s per-kind index query).
pub primary: String,
/// One kind-specific summary field: star systems -> star_type; bodies
/// -> body_type; stations -> station_type; corporations -> corp_type;
/// commodities -> tier; trait templates -> corridor_pool.
pub secondary: Option<String>,
}
/// One variant per entity kind, each carrying every column the detail
/// screen shows (D-254 §4: "showing every column the wire response
/// carries for that one entity"). The wrapped `*DetailRow` structs
/// (`browse_reader`) are `#[derive(Serialize, Deserialize)]`-free by
/// design (DB-layer types); this proxy layer owns the wire encoding, so a
/// thin field-for-field wire struct mirrors each one 1:1.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BrowseDetail {
StarSystem(StarSystemDetail),
Body(BodyDetail),
Station(StationDetail),
Corporation(CorporationDetail),
Commodity(CommodityDetail),
TraitTemplate(TraitTemplateDetail),
}
/// A browse response: the index list or one detail row (matching the
/// request's `query` form), or a non-`Ready` status (T-1131).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowseResponse {
/// Echoes the request's `kind` — lets the client route the response
/// without needing to correlate against its own outstanding-request
/// state (the connection-tagging in `crate::bridge` already handles
/// "whose request was this"; this is "which of my SIX outstanding
/// concerns is this").
pub kind: BrowseEntityKind,
pub status: BrowseStatus,
/// Populated iff the request's `query` was `Index` and `status ==
/// Ready`.
pub index: Option<Vec<BrowseIndexRow>>,
/// Populated iff the request's `query` was `Detail` and `status ==
/// Ready`.
pub detail: Option<BrowseDetail>,
}
// ---------------------------------------------------------------------------
// Wire-shaped detail structs (1:1 field mirror of browse_reader's DB-layer
// *DetailRow structs — see BrowseDetail doc for why this indirection exists)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StarSystemDetail {
pub system_id: String,
pub proper_name: Option<String>,
pub system_name: Option<String>,
pub star_type: Option<String>,
pub spectral_class: Option<String>,
pub dist_ly: Option<f64>,
pub geographic_sector: Option<String>,
pub geographic_band: Option<String>,
pub political_zone: Option<String>,
pub habitable_planet_count: Option<i64>,
pub inhabited_planet_count: Option<i64>,
pub asteroid_belt: Option<bool>,
pub gas_giant: Option<bool>,
pub habitability_profile: Option<String>,
pub earth_alignment: Option<String>,
pub earth_proximity: Option<String>,
pub earth_tension: Option<String>,
pub stability_index: Option<i64>,
pub system_volatility: Option<String>,
pub cultural_corridor: Option<String>,
pub currency_zone: Option<String>,
pub economic_tier: Option<i64>,
pub population: Option<i64>,
pub economic_base_primary: Option<String>,
pub economic_base_secondary: Option<String>,
pub governance_type: Option<String>,
pub dominant_faction: Option<String>,
pub cultural_register: Option<String>,
pub atmospheric_tone: Option<String>,
pub primary_archetype: Option<String>,
}
impl From<StarSystemDetailRow> for StarSystemDetail {
fn from(r: StarSystemDetailRow) -> Self {
Self {
system_id: r.system_id,
proper_name: r.proper_name,
system_name: r.system_name,
star_type: r.star_type,
spectral_class: r.spectral_class,
dist_ly: r.dist_ly,
geographic_sector: r.geographic_sector,
geographic_band: r.geographic_band,
political_zone: r.political_zone,
habitable_planet_count: r.habitable_planet_count,
inhabited_planet_count: r.inhabited_planet_count,
asteroid_belt: r.asteroid_belt,
gas_giant: r.gas_giant,
habitability_profile: r.habitability_profile,
earth_alignment: r.earth_alignment,
earth_proximity: r.earth_proximity,
earth_tension: r.earth_tension,
stability_index: r.stability_index,
system_volatility: r.system_volatility,
cultural_corridor: r.cultural_corridor,
currency_zone: r.currency_zone,
economic_tier: r.economic_tier,
population: r.population,
economic_base_primary: r.economic_base_primary,
economic_base_secondary: r.economic_base_secondary,
governance_type: r.governance_type,
dominant_faction: r.dominant_faction,
cultural_register: r.cultural_register,
atmospheric_tone: r.atmospheric_tone,
primary_archetype: r.primary_archetype,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BodyDetail {
pub body_id: String,
pub system_id: String,
pub parent_body_id: Option<String>,
pub body_type: String,
pub orbit_index: Option<i64>,
pub proper_name: Option<String>,
pub mass_class: Option<String>,
pub atmosphere: Option<String>,
pub surface_gravity: Option<f64>,
pub orbital_period_days: Option<f64>,
pub rotation_period_hours: Option<f64>,
pub planet_class: Option<String>,
pub hydrosphere: Option<String>,
pub biosphere_class: Option<String>,
pub inhabited: bool,
pub population: Option<i64>,
pub economic_role: Option<String>,
pub founding_age_years: Option<i64>,
pub settlement_pattern: Option<String>,
pub cultural_corridor: Option<String>,
pub industrial_corridor: Option<String>,
pub body_radius_km: Option<f64>,
pub axial_tilt_deg: Option<f64>,
}
impl From<BodyDetailRow> for BodyDetail {
fn from(r: BodyDetailRow) -> Self {
Self {
body_id: r.body_id,
system_id: r.system_id,
parent_body_id: r.parent_body_id,
body_type: r.body_type,
orbit_index: r.orbit_index,
proper_name: r.proper_name,
mass_class: r.mass_class,
atmosphere: r.atmosphere,
surface_gravity: r.surface_gravity,
orbital_period_days: r.orbital_period_days,
rotation_period_hours: r.rotation_period_hours,
planet_class: r.planet_class,
hydrosphere: r.hydrosphere,
biosphere_class: r.biosphere_class,
inhabited: r.inhabited,
population: r.population,
economic_role: r.economic_role,
founding_age_years: r.founding_age_years,
settlement_pattern: r.settlement_pattern,
cultural_corridor: r.cultural_corridor,
industrial_corridor: r.industrial_corridor,
body_radius_km: r.body_radius_km,
axial_tilt_deg: r.axial_tilt_deg,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StationDetail {
pub station_id: String,
pub system_id: String,
pub orbits_body_id: Option<String>,
pub station_type: String,
pub proper_name: Option<String>,
pub population: Option<i64>,
pub economic_role: Option<String>,
pub governance_type: Option<String>,
pub docking_class: Option<String>,
pub has_gate_infrastructure: bool,
pub district_count: Option<i64>,
}
impl From<StationDetailRow> for StationDetail {
fn from(r: StationDetailRow) -> Self {
Self {
station_id: r.station_id,
system_id: r.system_id,
orbits_body_id: r.orbits_body_id,
station_type: r.station_type,
proper_name: r.proper_name,
population: r.population,
economic_role: r.economic_role,
governance_type: r.governance_type,
docking_class: r.docking_class,
has_gate_infrastructure: r.has_gate_infrastructure,
district_count: r.district_count,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CorpPresenceEntry {
pub location_id: String,
pub location_type: String,
pub primary_operation: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CorporationDetail {
pub corp_id: String,
pub proper_name: String,
pub corp_type: String,
pub scope: Option<String>,
pub headquarters_system: Option<String>,
pub headquarters_body: Option<String>,
pub specialization: Option<String>,
pub parent_corp: Option<String>,
pub notes: Option<String>,
pub behavioral_archetype: Option<String>,
pub supply_chain_role: Option<String>,
pub shadow_economy_access: bool,
pub corp_specialization: Option<String>,
pub hq_placement: Option<String>,
pub health_metric: Option<f64>,
pub presence: Vec<CorpPresenceEntry>,
}
impl From<CorporationDetailRow> for CorporationDetail {
fn from(r: CorporationDetailRow) -> Self {
Self {
corp_id: r.corp_id,
proper_name: r.proper_name,
corp_type: r.corp_type,
scope: r.scope,
headquarters_system: r.headquarters_system,
headquarters_body: r.headquarters_body,
specialization: r.specialization,
parent_corp: r.parent_corp,
notes: r.notes,
behavioral_archetype: r.behavioral_archetype,
supply_chain_role: r.supply_chain_role,
shadow_economy_access: r.shadow_economy_access,
corp_specialization: r.corp_specialization,
hq_placement: r.hq_placement,
health_metric: r.health_metric,
presence: r
.presence
.into_iter()
.map(|p| CorpPresenceEntry {
location_id: p.location_id,
location_type: p.location_type,
primary_operation: p.primary_operation,
})
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChainInputEntry {
pub input_commodity_id: String,
pub quantity: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProductionChainEntry {
pub chain_id: String,
pub output_quantity: f64,
pub location_bound: bool,
pub description: Option<String>,
pub inputs: Vec<ChainInputEntry>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CommodityDetail {
pub commodity_id: String,
pub name: String,
pub tier: String,
pub elasticity: String,
pub base_price: f64,
pub bulk_class: Option<String>,
pub unit: Option<String>,
pub production_ubiquity: Option<String>,
pub demand_model: Option<String>,
pub commission_certifiable: bool,
pub compact_contested: bool,
pub shadow_viable: bool,
pub panic_threshold_weeks: Option<i64>,
pub description: Option<String>,
pub produced_by: Vec<ProductionChainEntry>,
}
impl From<CommodityDetailRow> for CommodityDetail {
fn from(r: CommodityDetailRow) -> Self {
Self {
commodity_id: r.commodity_id,
name: r.name,
tier: r.tier,
elasticity: r.elasticity,
base_price: r.base_price,
bulk_class: r.bulk_class,
unit: r.unit,
production_ubiquity: r.production_ubiquity,
demand_model: r.demand_model,
commission_certifiable: r.commission_certifiable,
compact_contested: r.compact_contested,
shadow_viable: r.shadow_viable,
panic_threshold_weeks: r.panic_threshold_weeks,
description: r.description,
produced_by: r
.produced_by
.into_iter()
.map(|c| ProductionChainEntry {
chain_id: c.chain_id,
output_quantity: c.output_quantity,
location_bound: c.location_bound,
description: c.description,
inputs: c
.inputs
.into_iter()
.map(|i| ChainInputEntry {
input_commodity_id: i.input_commodity_id,
quantity: i.quantity,
})
.collect(),
})
.collect(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TraitTemplateDetail {
pub tag: String,
pub label: String,
pub cultural_description: Option<String>,
pub corridor_pool: String,
pub geographic_sector: Option<String>,
pub bulk_class_gate: Option<String>,
pub production_ubiquity_gate: Option<String>,
pub min_prosperity_bps: i64,
pub base_weight: i64,
pub weight_mods: Option<String>,
pub zone_affinity: Option<String>,
pub allow_tags: Option<String>,
pub block_tags: Option<String>,
pub era_scope: Option<String>,
pub visual_bundle: Option<String>,
}
impl From<TraitTemplateDetailRow> for TraitTemplateDetail {
fn from(r: TraitTemplateDetailRow) -> Self {
Self {
tag: r.tag,
label: r.label,
cultural_description: r.cultural_description,
corridor_pool: r.corridor_pool,
geographic_sector: r.geographic_sector,
bulk_class_gate: r.bulk_class_gate,
production_ubiquity_gate: r.production_ubiquity_gate,
min_prosperity_bps: r.min_prosperity_bps,
base_weight: r.base_weight,
weight_mods: r.weight_mods,
zone_affinity: r.zone_affinity,
allow_tags: r.allow_tags,
block_tags: r.block_tags,
era_scope: r.era_scope,
visual_bundle: r.visual_bundle,
}
}
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
fn error_response(kind: BrowseEntityKind, message: impl Into<String>) -> BrowseResponse {
BrowseResponse {
kind,
status: BrowseStatus::Error(message.into()),
index: None,
detail: None,
}
}
fn db_error_response(kind: BrowseEntityKind, e: BrowseReadError) -> BrowseResponse {
error_response(kind, e.to_string())
}
/// Serve one browse request (T-1131): dispatch on `(kind, query)` to the
/// matching `BrowseReader` read function, ignoring `filter_system_id` for
/// every kind except `Body` (only `Body`'s `Index` query honors it, per
/// D-254 §4 — see [`BrowseQuery::Index`] doc).
///
/// `reader` absent (no DB opened at startup) is reported as `Error`,
/// matching `handle_city_names_request`'s "no city context reader"
/// convention.
pub fn handle_browse_request(req: &BrowseRequest, reader: Option<&BrowseReader>) -> BrowseResponse {
let Some(reader) = reader else {
return error_response(req.kind, "browse reader unavailable");
};
match (req.kind, &req.query) {
// ─── Star systems ───────────────────────────────────────────────
(BrowseEntityKind::StarSystem, BrowseQuery::Index { .. }) => {
match reader.index_star_systems() {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.system_id,
primary: r.display_name,
secondary: r.star_type,
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
(BrowseEntityKind::StarSystem, BrowseQuery::Detail { id }) => {
match reader.detail_star_system(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::StarSystem(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
// ─── Bodies ──────────────────────────────────────────────────────
(BrowseEntityKind::Body, BrowseQuery::Index { filter_system_id }) => {
match reader.index_bodies(filter_system_id.as_deref()) {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.body_id,
primary: r.display_name,
secondary: Some(r.body_type),
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
(BrowseEntityKind::Body, BrowseQuery::Detail { id }) => match reader.detail_body(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::Body(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
},
// ─── Stations ────────────────────────────────────────────────────
(BrowseEntityKind::Station, BrowseQuery::Index { .. }) => match reader.index_stations() {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.station_id,
primary: r.display_name,
secondary: Some(r.station_type),
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
},
(BrowseEntityKind::Station, BrowseQuery::Detail { id }) => {
match reader.detail_station(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::Station(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
// ─── Corporations ────────────────────────────────────────────────
(BrowseEntityKind::Corporation, BrowseQuery::Index { .. }) => {
match reader.index_corporations() {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.corp_id,
primary: r.proper_name,
secondary: Some(r.corp_type),
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
(BrowseEntityKind::Corporation, BrowseQuery::Detail { id }) => {
match reader.detail_corporation(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::Corporation(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
// ─── Commodities ─────────────────────────────────────────────────
(BrowseEntityKind::Commodity, BrowseQuery::Index { .. }) => {
match reader.index_commodities() {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.commodity_id,
primary: r.name,
secondary: Some(r.tier),
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
(BrowseEntityKind::Commodity, BrowseQuery::Detail { id }) => {
match reader.detail_commodity(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::Commodity(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
// ─── Trait templates ─────────────────────────────────────────────
(BrowseEntityKind::TraitTemplate, BrowseQuery::Index { .. }) => {
match reader.index_trait_templates() {
Ok(rows) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: Some(
rows.into_iter()
.map(|r| BrowseIndexRow {
id: r.tag,
primary: r.label,
secondary: Some(r.corridor_pool),
})
.collect(),
),
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
(BrowseEntityKind::TraitTemplate, BrowseQuery::Detail { id }) => {
match reader.detail_trait_template(id) {
Ok(Some(row)) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::Ready,
index: None,
detail: Some(BrowseDetail::TraitTemplate(row.into())),
},
Ok(None) => BrowseResponse {
kind: req.kind,
status: BrowseStatus::NotFound,
index: None,
detail: None,
},
Err(e) => db_error_response(req.kind, e),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::browse_reader::tests::make_fixture_db;
fn index_req(kind: BrowseEntityKind) -> BrowseRequest {
BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Index {
filter_system_id: None,
},
}
}
fn detail_req(kind: BrowseEntityKind, id: &str) -> BrowseRequest {
BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Detail { id: id.to_string() },
}
}
// ─── Wire encoding shape (pinned to stig-browser 2026-07-17: unit
// variants of BrowseEntityKind/BrowseStatus are bare strings; the
// data-carrying variants of BrowseQuery/BrowseStatus::Error are
// single-key maps — same convention as CascadeLayer/AtlasLayerStatus) ──
#[test]
fn browse_entity_kind_encodes_as_bare_string() {
// All six BrowseEntityKind variants are unit variants -> bare
// MessagePack string, exactly like CascadeLayer::Topography does for
// AtlasLayerRequest.up_to. Round-trip through serde_json (which makes
// the shape human-inspectable) to assert this precisely, then confirm
// the msgpack round-trip separately.
let json = serde_json::to_string(&BrowseEntityKind::StarSystem).expect("encode");
assert_eq!(json, "\"StarSystem\"");
}
#[test]
fn browse_query_variants_encode_as_single_key_maps() {
// Both BrowseQuery variants carry fields -> single-key map on the
// wire, matching AtlasLayerStatus::Error(String)'s convention.
let index_json = serde_json::to_string(&BrowseQuery::Index {
filter_system_id: Some("GJ-1".to_string()),
})
.expect("encode");
assert_eq!(index_json, "{\"Index\":{\"filter_system_id\":\"GJ-1\"}}");
let detail_json = serde_json::to_string(&BrowseQuery::Detail {
id: "GJ1c".to_string(),
})
.expect("encode");
assert_eq!(detail_json, "{\"Detail\":{\"id\":\"GJ1c\"}}");
}
#[test]
fn browse_status_unit_variants_are_bare_strings_error_is_a_map() {
assert_eq!(
serde_json::to_string(&BrowseStatus::Ready).unwrap(),
"\"Ready\""
);
assert_eq!(
serde_json::to_string(&BrowseStatus::NotFound).unwrap(),
"\"NotFound\""
);
assert_eq!(
serde_json::to_string(&BrowseStatus::Error("boom".to_string())).unwrap(),
"{\"Error\":\"boom\"}"
);
}
#[test]
fn browse_request_round_trips_msgpack() {
let req = index_req(BrowseEntityKind::Corporation);
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode");
assert!(decoded.browse);
assert_eq!(decoded.kind, BrowseEntityKind::Corporation);
assert!(matches!(decoded.query, BrowseQuery::Index { .. }));
let req = detail_req(BrowseEntityKind::Body, "GJ1c");
let bytes = rmp_serde::to_vec_named(&req).expect("encode");
let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode");
match decoded.query {
BrowseQuery::Detail { id } => assert_eq!(id, "GJ1c"),
other => panic!("expected Detail, got {other:?}"),
}
}
#[test]
fn browse_response_round_trips_msgpack() {
let resp = BrowseResponse {
kind: BrowseEntityKind::Commodity,
status: BrowseStatus::Ready,
index: Some(vec![BrowseIndexRow {
id: "fusion_fuel".into(),
primary: "Fusion Fuel".into(),
secondary: Some("intermediate".into()),
}]),
detail: None,
};
let bytes = rmp_serde::to_vec_named(&resp).expect("encode");
let decoded: BrowseResponse = rmp_serde::from_slice(&bytes).expect("decode");
assert_eq!(decoded.status, BrowseStatus::Ready);
assert_eq!(decoded.index.unwrap()[0].primary, "Fusion Fuel");
}
// ─── decode_inbound demux: BrowseRequest is disambiguated from the
// other four shapes, and doesn't collide with CityNamesRequest despite
// both being maps with no shared required field ──────────────────────
#[test]
fn decode_inbound_routes_browse_requests() {
let req = detail_req(BrowseEntityKind::StarSystem, "GJ-1");
let frame = rmp_serde::to_vec_named(&req).unwrap();
let decoded = crate::bridge::decode_inbound(&frame).expect("decode");
assert!(matches!(
decoded,
crate::bridge::Inbound::BrowseRequest(r) if r.kind == BrowseEntityKind::StarSystem
));
}
#[test]
fn decode_inbound_browse_does_not_collide_with_city_names() {
// A BrowseRequest frame has no body_id/city_names/star_map/up_to keys
// at all, so it must decode as BrowseRequest, never accidentally as
// one of the other four shapes.
let req = index_req(BrowseEntityKind::Station);
let frame = rmp_serde::to_vec_named(&req).unwrap();
assert!(
rmp_serde::from_slice::<crate::atlas::atlas_data_proxy::CityNamesRequest>(&frame)
.is_err()
);
assert!(
rmp_serde::from_slice::<crate::atlas::layer_proxy::AtlasLayerRequest>(&frame).is_err()
);
}
// ─── Handler dispatch (D-254 §4 six kinds x index/detail) ────────────
#[test]
fn handle_browse_request_no_reader_is_error_for_every_kind() {
for kind in [
BrowseEntityKind::StarSystem,
BrowseEntityKind::Body,
BrowseEntityKind::Station,
BrowseEntityKind::Corporation,
BrowseEntityKind::Commodity,
BrowseEntityKind::TraitTemplate,
] {
let resp = handle_browse_request(&index_req(kind), None);
assert!(
matches!(resp.status, BrowseStatus::Error(_)),
"kind {kind:?} should error without a reader"
);
assert!(resp.index.is_none());
assert!(resp.detail.is_none());
}
}
#[test]
fn handle_browse_request_index_and_detail_for_every_kind() {
let db = make_fixture_db();
let reader = BrowseReader::open(&db).expect("open");
// (kind, a known-good detail id) — proves both forms work for all
// six entity kinds end-to-end through the handler, not just the
// reader layer.
let cases: &[(BrowseEntityKind, &str)] = &[
(BrowseEntityKind::StarSystem, "GJ-1"),
(BrowseEntityKind::Body, "GJ1c"),
(BrowseEntityKind::Station, "GJ1c-S1"),
(BrowseEntityKind::Corporation, "gate-corporation"),
(BrowseEntityKind::Commodity, "fusion_fuel"),
(BrowseEntityKind::TraitTemplate, "frontier_utilitarian"),
];
for &(kind, id) in cases {
let index_resp = handle_browse_request(&index_req(kind), Some(&reader));
assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})");
assert_eq!(index_resp.kind, kind);
let rows = index_resp.index.expect("index populated");
assert!(!rows.is_empty(), "index({kind:?}) should be non-empty");
assert!(
rows.iter().any(|r| r.id == id),
"index({kind:?}) should contain id {id}"
);
let detail_resp = handle_browse_request(&detail_req(kind, id), Some(&reader));
assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})");
assert_eq!(detail_resp.kind, kind);
assert!(
detail_resp.detail.is_some(),
"detail({kind:?}) should be populated"
);
}
}
#[test]
fn handle_browse_request_body_index_honors_filter_system_id() {
let db = make_fixture_db();
let reader = BrowseReader::open(&db).expect("open");
let req = BrowseRequest {
browse: true,
kind: BrowseEntityKind::Body,
query: BrowseQuery::Index {
filter_system_id: Some("GJ-1".to_string()),
},
};
let resp = handle_browse_request(&req, Some(&reader));
let rows = resp.index.expect("index");
assert_eq!(rows.len(), 2, "only GJ-1's bodies");
assert!(rows.iter().all(|r| r.id != "GJ2b"));
}
#[test]
fn handle_browse_request_detail_unknown_id_is_not_found_for_every_kind() {
let db = make_fixture_db();
let reader = BrowseReader::open(&db).expect("open");
for kind in [
BrowseEntityKind::StarSystem,
BrowseEntityKind::Body,
BrowseEntityKind::Station,
BrowseEntityKind::Corporation,
BrowseEntityKind::Commodity,
BrowseEntityKind::TraitTemplate,
] {
let resp = handle_browse_request(&detail_req(kind, "no-such-id"), Some(&reader));
assert_eq!(
resp.status,
BrowseStatus::NotFound,
"detail({kind:?}, unknown id) should be NotFound"
);
assert!(resp.detail.is_none());
}
}
#[test]
fn handle_browse_request_index_on_kind_with_no_rows_is_ready_empty() {
// Fixture db's stations table has one row for every OTHER test, but a
// freshly created db with the table present and zero rows must still
// be Ready+empty, not an error — matches CityNamesStatus's
// "unknown/empty -> Ready, not NotFound" convention (see
// browse_reader::tests::index_stations_empty_table_is_empty_vec for
// the reader-layer proof; this is the handler-layer proof).
let db = make_fixture_db();
let reader = BrowseReader::open(&db).expect("open");
// trait_templates has exactly one row in the fixture; assert Ready
// with that one row rather than empty, to also prove non-empty index
// requests thread through status correctly (empty case already
// covered at the reader layer).
let resp =
handle_browse_request(&index_req(BrowseEntityKind::TraitTemplate), Some(&reader));
assert_eq!(resp.status, BrowseStatus::Ready);
assert_eq!(resp.index.unwrap().len(), 1);
}
#[test]
fn browse_detail_kind_and_field_shape_matches_dispatched_kind() {
// A StarSystem detail request must produce a BrowseDetail::StarSystem
// variant carrying that system's actual data — proves the per-kind
// BrowseDetail wrapping (not just the top-level status/kind) is wired
// correctly, not just that SOME detail came back.
let db = make_fixture_db();
let reader = BrowseReader::open(&db).expect("open");
let resp = handle_browse_request(
&detail_req(BrowseEntityKind::StarSystem, "GJ-1"),
Some(&reader),
);
match resp.detail {
Some(BrowseDetail::StarSystem(d)) => {
assert_eq!(d.system_id, "GJ-1");
assert_eq!(d.proper_name.as_deref(), Some("Aldren"));
}
other => panic!("expected BrowseDetail::StarSystem, got {other:?}"),
}
}
}