Skip to main content

ballistics_engine/bridge/
mod.rs

1//! Versioned JSON command bridge for embedded (mobile/FFI) consumers.
2//!
3//! One entry point, [`bridge_call`], accepts a JSON envelope and returns a JSON
4//! envelope. Request semantics live in the transport-free library services
5//! (starting with [`crate::solve_v1()`]); this module contains only the envelope
6//! contract, command dispatch, and panic containment. The C ABI wrapper lives in
7//! [`crate::bridge::ffi`] (feature `ffi`).
8//!
9//! ## Envelope contract (v1)
10//!
11//! Request:
12//! ```json
13//! { "api_version": 1, "command": "solve", "request": { ... } }
14//! ```
15//!
16//! Success response:
17//! ```json
18//! { "ok": true, "api_version": 1, "engine_version": "0.33.1",
19//!   "command": "solve", "result": { ... } }
20//! ```
21//!
22//! Error response (always in-band; the bridge never signals failure any other way):
23//! ```json
24//! { "ok": false, "api_version": 1, "engine_version": "0.33.1",
25//!   "error": { "code": "command_failed", "message": "...", "details": { ... } } }
26//! ```
27//!
28//! Compatibility policy: the envelope itself rejects unknown fields (a caller that
29//! misspells `command` should hear about it), while inner `request` payloads follow
30//! each command's own schema discipline (e.g. `solve` uses the solve-json v1
31//! decoder, which also rejects unknown fields with location info). New commands
32//! and new OPTIONAL response fields may appear within api_version 1; anything that
33//! would break an existing well-formed caller bumps `BRIDGE_API_VERSION`. Callers
34//! feature-detect with `meta.capabilities` instead of sniffing versions.
35//!
36//! ## The `atmosphere.*` family
37//!
38//! `atmosphere.density_altitude` describes the air a solve would fly through, without
39//! solving anything (`crate::atmosphere_service`). Its request is `{"atmosphere": {...}}`
40//! where the inner object is EXACTLY solve-json v1's `atmosphere` — SI units, humidity as a
41//! fraction, optional `pressure_reference` — decoded by the solve's own shape validator and
42//! resolved by the solve's own `resolve_atmosphere`. Validation errors, ICAO defaults
43//! (announced in `assumptions`) and the QNH reduction are therefore the solve's: the air
44//! described is the air solved through. On top of that it refuses air the solve does not yet
45//! refuse — a temperature outside −100..100 °C, a pressure or density altitude outside the
46//! troposphere, a humidity beyond the total pressure — which is what °C sent as kelvin or hPa
47//! sent as pascals looks like.
48//!
49//! The result reports pressure altitude, the CIPM-2007 air density the solver uses, and
50//! density altitude under TWO names, never one: `faa_rule` (NWS pressure altitude plus the
51//! FAA 120 ft/°C rule; humidity-free, the formula the DOPE card header uses, the exact
52//! inverse of density-altitude entry) and `density_matched` (the ISA altitude of equal actual
53//! density, humidity included — what humidity-aware tools, the NWS's own calculator among
54//! them, report). They differ for reasons other than humidity — the FAA rule is a straight
55//! line — so an app must pick one by name and must not label their difference a humidity
56//! correction. Every altitude carries both `m` and `ft`.
57//!
58//! Errors are shaped like `solve`'s: a missing payload is `invalid_request`; anything wrong
59//! with the payload — a malformed or unit-suffixed field, an out-of-range value, impossible
60//! air — is `command_failed` with the solve-json error envelope (`$.atmosphere.*` path) in
61//! `error.details`. Unconditional — present on every target including wasm32 — and additive
62//! within api_version 1.
63//!
64//! ## `.a7p` interop: `profile.import_a7p` and `profile.export_a7p`
65//!
66//! Both directions of the ArcherBC2 format, and both are LOSSY in a way the caller is
67//! required to surface. Import reports `unmapped` — what the file held that a
68//! `ProfileData` cannot. Export (MBA-1556) reports `not_carried` — every `ProfileData`
69//! field the file cannot hold, listed unconditionally with a `populated` flag, plus the
70//! pre-filtered `dropped_fields` for the ones this profile really did lose.
71//!
72//! The asymmetry is deliberate. On import the user is gaining a profile and can see what
73//! arrived; on export they are handing a rifle to somebody else and cannot see what
74//! arrives at the far end. An app that renders "exported" without rendering the drop list
75//! has told a shooter something untrue, so the list travels in the result rather than
76//! being something a caller has to ask for.
77//!
78//! Export never extends the format to make room: `.a7p` belongs to somebody else, and
79//! smuggling our fields into unused field numbers would produce files Archer's own tools
80//! misread.
81//!
82//! ## The `reticle.*` family
83//!
84//! `reticle.describe`, `reticle.hold` and `reticle.import` put the engine's reticle stack
85//! (MBA-1361, MBA-1440, MBA-1544) in reach of an app. Before MBA-1558 none of it was: the
86//! bridge named no reticle command, and since the apps vendor only `ballistics_bridge_call`
87//! and friends, a capability the bridge does not name does not exist for them.
88//!
89//! - `reticle.hold` is the one the family exists for — given a firing solution ALREADY
90//!   reduced to angles and the optic's current magnification, it reports which mark to hold
91//!   on. It runs no physics: the angles are inputs, and `crate::reticle` keeps its
92//!   no-physics property precisely because nothing here re-derives them.
93//! - `reticle.describe` resolves a reticle, supplied inline or built from a generator, and
94//!   optionally reports every mark's TRUE angular position at a magnification. This is what
95//!   a picker and a drawing are built on. It carries `focal_plane` and
96//!   `magnification_dependent` so a UI knows whether a magnification control changes
97//!   anything.
98//! - `reticle.import` reads the two third-party formats the crate already parses (Ventum
99//!   JSON, `.reticle` XML), each with its report. Both are TEXT and travel inline, so
100//!   unlike `profile.import_a7p` there is no base64 step.
101//! - `reticle.catalog` lists the NAMED reticles this build can produce
102//!   (`crate::reticle_catalog`, MBA-1545), which is what an app picker is populated from —
103//!   hardcoding ids in an app means a picker that offers what a pinned engine refuses. Each
104//!   entry carries its `source`, and that provenance reaches the wire on purpose: a wrong
105//!   subtension is a hold wrong by a mark and looks entirely normal, so what makes it
106//!   traceable belongs where the person reading the reticle can see it.
107//!
108//! Two shapes are shared across the family and are worth stating once. A reticle is named
109//! by EXACTLY ONE of `reticle` (a full description), `generator`, or `catalog` (an id from
110//! `reticle.catalog`) — a request naming more than one is refused rather than resolved by
111//! precedence, because supplying more than one is a caller bug and picking a winner hides
112//! it. An unknown catalog id is refused for the same reason and never substituted: a
113//! shooter handed a different reticle is being shown holds for glass they are not looking
114//! through. And because every generator returns FFP,
115//! `focal_plane` / `reference_magnification` may be supplied ALONGSIDE a generator to make
116//! an SFP reticle; the same keys beside a full `reticle` are refused, since that
117//! description already carries its own.
118//!
119//! Errors follow `true.dsf`'s convention: `error.code` stays `command_failed` and a stable
120//! `reason` rides in `error.details` (one per `ReticleError` variant) with the offending
121//! numbers beside it. Additive within api_version 1, and listed by `meta.capabilities`.
122//!
123//! ## The `true.*` truing family
124//!
125//! `true.fit`, `true.wind`, `true.tall_target`, `true.dsf`, `true.plan`, and `true.dial_plan`
126//! expose the engine's truing methods. All six are unconditional (no filesystem access, so
127//! all six are present on wasm32), but they are not otherwise uniform:
128//!
129//! - `true.fit` (joint MV+BC truing) is backed by the uncertainty solver, so its result
130//!   always carries `approximation` — a required enum that is either `Available` with
131//!   intervals for both muzzle velocity and BC, or `Unavailable` with a reason, never simply
132//!   absent. There is deliberately no command that returns a bare truing point estimate.
133//! - `true.wind` (effective crosswind from an observed miss) is the one exception to that
134//!   guarantee: `solve_wind_truing` has no uncertainty model, so its result is a bare point
135//!   value with no interval. Callers must not present it with `true.fit`'s confidence.
136//!   `true.wind` is also the one command whose wire shape is SI throughout (`range_m`,
137//!   `miss_right_m`, `sigma_m`) while every other command, including the rest of this
138//!   family, is imperial; apps convert at the boundary for `true.wind` specifically.
139//! - `true.tall_target` returns a scope's tracking correction factor from a tall-target
140//!   test.
141//! - `true.dsf` derives a single Mach-keyed drop-scale-factor point from an observed
142//!   transonic drop; it never persists into a profile's DSF table, which is the caller's
143//!   job. It established this family's structured-`error.details` convention: a
144//!   machine-readable `reason` (`invalid_input`, `supersonic`, `out_of_range`,
145//!   `degenerate_drop`, `forward_model`) alongside the message; `error.code` stays
146//!   `command_failed` for all commands, so existing callers are unaffected.
147//! - `true.plan` recommends which candidate ranges to shoot for a joint MV/BC truing
148//!   experiment (`crate::truing_plan::plan_truing_experiment_v1`, wired directly — no new
149//!   service function). Its error also carries structured `error.details`, under the same
150//!   `reason` key as every other command in this family (`invalid_request`,
151//!   `insufficient_reachable_candidates`, `no_feasible_design`) plus the `rejected_candidates`
152//!   diagnostics the typed error itself carries.
153//! - `true.dial_plan` turns a TRUE angular correction into ranked dial/hold/hybrid
154//!   execution plans for an INLINE optic (`crate::truing_service::dial_plan_v1`, wrapping
155//!   `crate::optic::plan_corrections`). Unlike the CLI's `dial-plan --profile` mode, there
156//!   is no profile-loading path here — the optic is supplied inline in the request, since a
157//!   saved-profile filesystem read must not enter this bridge. Its error also carries
158//!   structured `error.details`: a stable `reason` per `OpticError` variant.
159//!
160//! None of this needed a `BRIDGE_API_VERSION` bump: the six commands are additive within
161//! api_version 1, and `meta.capabilities` lists all six for feature detection.
162
163#[cfg(feature = "ffi")]
164pub mod ffi;
165
166use serde::{Deserialize, Serialize};
167use serde_json::{json, Value};
168use std::panic::{catch_unwind, AssertUnwindSafe};
169
170/// Bridge envelope version. Bumped only for breaking envelope changes.
171pub const BRIDGE_API_VERSION: u32 = 1;
172
173/// Hard cap on request size, matching the solve-json transport.
174pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
175
176const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
177
178/// Commands available in this build, in dispatch order.
179/// `meta.capabilities` reports exactly this list so apps can feature-detect.
180fn command_names() -> Vec<&'static str> {
181    let mut names = vec![
182        "meta.capabilities",
183        "meta.version",
184        "solve",
185        "atmosphere.density_altitude",
186        "card.come_ups",
187        "card.range_table",
188        "card.wind",
189    ];
190    // Listed ONLY when compiled in (mirroring compiled_features) so apps feature-detect
191    // the command list instead of probing for unknown_command. Each conditional push sits
192    // at its dispatch position so this list stays in dispatch order, as documented above.
193    #[cfg(feature = "pdf")]
194    names.push("card.pdf");
195    names.extend(["profile.validate", "profile.normalize"]);
196    #[cfg(feature = "profile-import")]
197    names.push("profile.import_a7p");
198    #[cfg(feature = "profile-export")]
199    names.push("profile.export_a7p");
200    // Unconditional: the reticle stack is pure geometry with no filesystem access, so all
201    // three are present on wasm32 like the `true.*` family.
202    names.extend([
203        "reticle.catalog",
204        "reticle.describe",
205        "reticle.hold",
206        "reticle.holds",
207        "reticle.import",
208    ]);
209    names.extend([
210        "true.fit",
211        "true.wind",
212        "true.tall_target",
213        "true.dsf",
214        "true.plan",
215        "true.dial_plan",
216    ]);
217    // Filesystem-backed (BC5D tables are loaded from caller-supplied paths), so absent on
218    // wasm32 — the same "list only what this build can run" rule as profile.import_a7p.
219    #[cfg(not(target_arch = "wasm32"))]
220    names.push("bc5d.info");
221    names
222}
223
224fn compiled_features() -> Vec<&'static str> {
225    [
226        ("pdf", cfg!(feature = "pdf")),
227        ("profile-import", cfg!(feature = "profile-import")),
228        ("profile-export", cfg!(feature = "profile-export")),
229        ("online", cfg!(feature = "online")),
230    ]
231    .iter()
232    .filter(|(_, enabled)| *enabled)
233    .map(|(name, _)| *name)
234    .collect()
235}
236
237#[derive(Debug, Deserialize)]
238#[serde(deny_unknown_fields)]
239struct BridgeRequest {
240    api_version: u32,
241    command: String,
242    #[serde(default)]
243    request: Value,
244}
245
246/// Machine-readable bridge error codes. Distinct from any command's own error
247/// vocabulary: a `command_failed` carries the command's typed error in `details`.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "snake_case")]
250pub enum BridgeErrorCode {
251    InvalidJson,
252    UnsupportedApiVersion,
253    UnknownCommand,
254    InvalidRequest,
255    ResourceLimit,
256    CommandFailed,
257    InternalError,
258}
259
260fn success(command: &str, result: Value) -> String {
261    serialize_envelope(&json!({
262        "ok": true,
263        "api_version": BRIDGE_API_VERSION,
264        "engine_version": ENGINE_VERSION,
265        "command": command,
266        "result": result,
267    }))
268}
269
270fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
271    let mut error = json!({
272        "code": code,
273        "message": message.into(),
274    });
275    if let Some(details) = details {
276        error["details"] = details;
277    }
278    serialize_envelope(&json!({
279        "ok": false,
280        "api_version": BRIDGE_API_VERSION,
281        "engine_version": ENGINE_VERSION,
282        "error": error,
283    }))
284}
285
286/// Serialization of the envelope itself must not be able to fail the bridge:
287/// fall back to a hand-written internal_error document.
288fn serialize_envelope(value: &Value) -> String {
289    serde_json::to_string(value).unwrap_or_else(|_| {
290        format!(
291            r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
292        )
293    })
294}
295
296/// Process one bridge exchange. Never panics; every failure mode is an in-band
297/// error envelope. This is the function the C ABI wraps.
298pub fn bridge_call(request_json: &str) -> String {
299    let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
300    guarded.unwrap_or_else(|_| {
301        error(
302            BridgeErrorCode::InternalError,
303            "bridge command failed unexpectedly",
304            None,
305        )
306    })
307}
308
309fn dispatch(request_json: &str) -> String {
310    if request_json.len() > MAX_REQUEST_BYTES {
311        return error(
312            BridgeErrorCode::ResourceLimit,
313            format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
314            None,
315        );
316    }
317
318    let request: BridgeRequest = match serde_json::from_str(request_json) {
319        Ok(request) => request,
320        Err(err) => {
321            return error(
322                BridgeErrorCode::InvalidJson,
323                format!("bridge request is not a valid envelope: {err}"),
324                None,
325            )
326        }
327    };
328
329    if request.api_version != BRIDGE_API_VERSION {
330        return error(
331            BridgeErrorCode::UnsupportedApiVersion,
332            format!(
333                "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
334                request.api_version
335            ),
336            None,
337        );
338    }
339
340    match request.command.as_str() {
341        "meta.capabilities" => success(
342            "meta.capabilities",
343            json!({
344                "engine_version": ENGINE_VERSION,
345                "bridge_api_version": BRIDGE_API_VERSION,
346                "commands": command_names(),
347                "features": compiled_features(),
348                "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
349            }),
350        ),
351        "meta.version" => success("meta.version", json!({ "engine_version": ENGINE_VERSION })),
352        "solve" => run_solve(&request.request),
353        "atmosphere.density_altitude" => run_atmosphere_density_altitude(&request.request),
354        "card.come_ups" => run_service(
355            &request.request,
356            "card.come_ups",
357            crate::card_service::come_ups_v1,
358        ),
359        "card.range_table" => run_service(
360            &request.request,
361            "card.range_table",
362            crate::card_service::range_table_v1,
363        ),
364        "card.wind" => run_service(
365            &request.request,
366            "card.wind",
367            crate::card_service::wind_card_v1,
368        ),
369        #[cfg(feature = "pdf")]
370        "card.pdf" => run_card_pdf(&request.request),
371        "profile.validate" => run_profile_validate(&request.request),
372        "profile.normalize" => run_profile_normalize(&request.request),
373        #[cfg(feature = "profile-import")]
374        "profile.import_a7p" => run_profile_import_a7p(&request.request),
375        #[cfg(feature = "profile-export")]
376        "profile.export_a7p" => run_profile_export_a7p(&request.request),
377        "reticle.catalog" => run_reticle_catalog(),
378        "reticle.describe" => run_reticle_describe(&request.request),
379        "reticle.hold" => run_reticle_hold(&request.request),
380        "reticle.holds" => run_reticle_holds(&request.request),
381        "reticle.import" => run_reticle_import(&request.request),
382        "true.fit" => run_service(
383            &request.request,
384            "true.fit",
385            crate::truing_uncertainty::run_uncertainty_truing_v1,
386        ),
387        "true.wind" => run_service(
388            &request.request,
389            "true.wind",
390            crate::truing_wind::solve_wind_truing,
391        ),
392        "true.tall_target" => run_service(
393            &request.request,
394            "true.tall_target",
395            crate::truing_service::tall_target_v1,
396        ),
397        "true.dsf" => run_service_detailed(
398            &request.request,
399            "true.dsf",
400            crate::truing_service::derive_dsf_point_v1,
401            crate::truing_service::DsfServiceErrorV1::failure_details,
402        ),
403        "true.plan" => run_service_detailed(
404            &request.request,
405            "true.plan",
406            crate::truing_plan::plan_truing_experiment_v1,
407            crate::truing_plan::TruingPlanErrorV1::failure_details,
408        ),
409        "true.dial_plan" => run_service_detailed(
410            &request.request,
411            "true.dial_plan",
412            crate::truing_service::dial_plan_v1,
413            crate::optic::OpticError::failure_details,
414        ),
415        #[cfg(not(target_arch = "wasm32"))]
416        "bc5d.info" => run_bc5d_info(&request.request),
417        other => error(
418            BridgeErrorCode::UnknownCommand,
419            format!(
420                "unknown command '{other}'; this build supports: {}",
421                command_names().join(", ")
422            ),
423            None,
424        ),
425    }
426}
427
428/// `solve` delegates verbatim to the solve-json v1 service. The inner request is
429/// re-serialized and run through [`crate::solve_json::decode_solve_request_v1`] so
430/// callers get the exact same schema validation (unknown-field rejection, explicit
431/// SI units, typed error locations) as the CLI `solve-json` transport.
432fn run_solve(inner: &Value) -> String {
433    if inner.is_null() {
434        return error(
435            BridgeErrorCode::InvalidRequest,
436            "'solve' requires a request payload (solve-json v1 document)",
437            None,
438        );
439    }
440    let inner_text = match serde_json::to_string(inner) {
441        Ok(text) => text,
442        Err(err) => {
443            return error(
444                BridgeErrorCode::InternalError,
445                format!("failed to re-serialize solve request: {err}"),
446                None,
447            )
448        }
449    };
450
451    let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
452        Ok(request) => request,
453        Err(envelope) => return command_error("solve request rejected", &envelope),
454    };
455
456    match crate::solve_v1(request) {
457        Ok(successful) => match serde_json::to_value(&successful) {
458            Ok(result) => success("solve", result),
459            Err(err) => error(
460                BridgeErrorCode::InternalError,
461                format!("failed to serialize solve result: {err}"),
462                None,
463            ),
464        },
465        Err(envelope) => command_error("solve failed", &envelope),
466    }
467}
468
469/// `atmosphere.density_altitude`: decode and resolve an atmosphere exactly as `solve` does and
470/// report its pressure altitude, both density altitudes, and its air density
471/// (`crate::atmosphere_service`). Shaped like `run_solve` rather than `run_service` so every
472/// rejection of the payload comes back as the solve's own path-located envelope in `details`,
473/// under the same `command_failed` code `solve` uses.
474fn run_atmosphere_density_altitude(inner: &Value) -> String {
475    const COMMAND: &str = "atmosphere.density_altitude";
476    if inner.is_null() {
477        return error(
478            BridgeErrorCode::InvalidRequest,
479            "'atmosphere.density_altitude' requires a request payload ({\"atmosphere\": {...}})",
480            None,
481        );
482    }
483    let outcome = crate::atmosphere_service::decode_density_altitude_request_v1(inner)
484        .and_then(|request| crate::atmosphere_service::density_altitude_v1(&request));
485    match outcome {
486        Ok(response) => match serde_json::to_value(&response) {
487            Ok(result) => success(COMMAND, result),
488            Err(err) => error(
489                BridgeErrorCode::InternalError,
490                format!("failed to serialize {COMMAND} result: {err}"),
491                None,
492            ),
493        },
494        Err(envelope) => command_error("atmosphere.density_altitude request rejected", &envelope),
495    }
496}
497
498/// Shared adapter for every command backed by a transport-free service: null-check the
499/// payload, deserialize the request, call the service, serialize the response. The error
500/// mapping is fixed so all commands report failures identically.
501fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
502where
503    Req: serde::de::DeserializeOwned,
504    Resp: serde::Serialize,
505    E: std::fmt::Display,
506    F: FnOnce(&Req) -> Result<Resp, E>,
507{
508    if inner.is_null() {
509        return error(
510            BridgeErrorCode::InvalidRequest,
511            format!("'{command}' requires a request payload"),
512            None,
513        );
514    }
515    let request: Req = match serde_json::from_value(inner.clone()) {
516        Ok(request) => request,
517        Err(err) => {
518            return error(
519                BridgeErrorCode::InvalidRequest,
520                format!("{command} request rejected: {err}"),
521                None,
522            )
523        }
524    };
525    match service(&request) {
526        Ok(response) => match serde_json::to_value(&response) {
527            Ok(result) => success(command, result),
528            Err(err) => error(
529                BridgeErrorCode::InternalError,
530                format!("failed to serialize {command} result: {err}"),
531                None,
532            ),
533        },
534        Err(err) => error(
535            BridgeErrorCode::CommandFailed,
536            format!("{command} failed: {err}"),
537            None,
538        ),
539    }
540}
541
542/// [`run_service`] for a service whose error carries a machine-readable reason.
543///
544/// `details` lands in `error.details` so a wizard can branch on "supersonic" or
545/// "out_of_range" instead of pattern-matching prose. `code` stays `command_failed`, so
546/// existing callers are unaffected.
547///
548/// Callers: `true.dsf`, `true.plan`, and `true.dial_plan`, each of whose service error
549/// carries a machine-readable reason worth surfacing in `error.details`.
550fn run_service_detailed<Req, Resp, E, F, D>(
551    inner: &Value,
552    command: &'static str,
553    service: F,
554    details: D,
555) -> String
556where
557    Req: serde::de::DeserializeOwned,
558    Resp: serde::Serialize,
559    E: std::fmt::Display,
560    F: FnOnce(&Req) -> Result<Resp, E>,
561    D: FnOnce(&E) -> Option<Value>,
562{
563    if inner.is_null() {
564        return error(
565            BridgeErrorCode::InvalidRequest,
566            format!("'{command}' requires a request payload"),
567            None,
568        );
569    }
570    let request: Req = match serde_json::from_value(inner.clone()) {
571        Ok(request) => request,
572        Err(err) => {
573            return error(
574                BridgeErrorCode::InvalidRequest,
575                format!("{command} request rejected: {err}"),
576                None,
577            )
578        }
579    };
580    match service(&request) {
581        Ok(response) => match serde_json::to_value(&response) {
582            Ok(result) => success(command, result),
583            Err(err) => error(
584                BridgeErrorCode::InternalError,
585                format!("failed to serialize {command} result: {err}"),
586                None,
587            ),
588        },
589        Err(err) => {
590            let d = details(&err);
591            error(
592                BridgeErrorCode::CommandFailed,
593                format!("{command} failed: {err}"),
594                d,
595            )
596        }
597    }
598}
599
600/// Hard cap on the PDF `card.pdf` will hand back, measured on the RAW document (the
601/// base64 text in the response is ~4/3 of it, so this bounds a ~5.6 MiB response body).
602///
603/// Every dope card carries a ~815 KiB floor: the two Liberation Sans faces
604/// `pdf_dope_card` embeds. Rows are cheap on top of that (~0.5 KiB each — a 300-row,
605/// 4-page card is ~950 KiB).
606///
607/// This is the BACKSTOP, not the first line: the row set is refused on its own row and page
608/// count before any document exists (`card_service::MAX_PDF_ROWS` / `MAX_PDF_PAGES`), because
609/// measuring bytes means having already built and paginated them. What survives that check
610/// and still lands here is a card made huge by its LABELS — the `pdf` block's strings are
611/// drawn verbatim on every page — and for those a typed refusal the caller can act on beats
612/// pushing a multi-megabyte base64 string through an embedded FFI hop.
613#[cfg(feature = "pdf")]
614pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
615
616/// The over-cap envelope for a generated PDF, or `None` when it fits. Split out so the
617/// boundary itself is unit-testable at exactly [`MAX_PDF_BYTES`] and one byte past it.
618///
619/// The message states what is true of the document — its size, and how many rows and pages
620/// it holds. It deliberately does NOT advise "coarsen the step" or "shorten the range
621/// domain": for a saved card those are immutable (there is no editor for a snapshot's
622/// domain), so naming them told the one user who ever sees this message to do something
623/// impossible.
624#[cfg(feature = "pdf")]
625fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
626    (byte_length > MAX_PDF_BYTES).then(|| {
627        error(
628            BridgeErrorCode::ResourceLimit,
629            format!(
630                "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
631                 ({row_count} rows, {page_count} pages)"
632            ),
633            None,
634        )
635    })
636}
637
638/// The one `card.pdf`-only key on the request: the rows to print, instead of solving. Not a
639/// field on [`crate::card_service::CardRequestV1`], because it is not part of a saved card —
640/// it is the card's stored RESPONSE, attached at export time — and a stored request must stay
641/// replayable against `card.range_table` unchanged. Removed from the payload before the card
642/// request is decoded, so `deny_unknown_fields` still governs everything else (a
643/// `stored_cards` typo is an honest `invalid_request`).
644#[cfg(feature = "pdf")]
645const STORED_CARD_KEY: &str = "stored_card";
646
647/// `card.pdf`: the printable dope card, as base64. The request is the SAME
648/// [`crate::card_service::CardRequestV1`] the on-screen card commands take — an app stores
649/// one request per saved card and replays it here — with the optional presentation-only
650/// `pdf` block for the header/footer labels, font size, and the Lead column's target speed,
651/// plus one `card.pdf`-only key:
652///
653/// * `stored_card` (optional): `{ "card": <a stored card.range_table result, verbatim>,
654///   "engine_version": "0.34.1", "bc5d_table_version": "2.5.0" }`. Supply it and this
655///   command PRINTS THOSE ROWS: no zero solve, no trajectory, and `bc5d_table_path` is never
656///   opened, so a saved card reprints identically after an engine bump, after the correction
657///   table at that path is overwritten in place, and even after it is deleted. The footer's
658///   `BC:` is the stored card's ladder range when its `bc_ladder` is present (MBA-1601),
659///   otherwise its own `bc_for_solve`, and its `Engine:`/`Table:` are the two provenance
660///   strings, so paper and screen can be reconciled afterwards.
661/// * Omit it (or send `null`, which means the same thing) and the rows are solved here, from
662///   the same `card_service::range_table_rows` call `card.range_table` makes — the
663///   pre-existing behaviour, unchanged.
664///
665/// This surface prints a range-table card and says so. A request carrying a wind card's
666/// `wind_speeds`/`wind_angles_deg`, or a `stored_card` of another kind, is REFUSED: an `ok`
667/// response whose defining field was silently ignored is worse than no response.
668///
669/// Result: `{ "pdf_base64": ..., "byte_length": <raw PDF bytes>, "page_count": ...,
670/// "row_count": ..., "kind": "range_table", "source": "solve" | "stored_rows",
671/// "unprintable_title_chars": "" }`.
672/// `byte_length` describes the DECODED document, not the base64 text; `source` lets a caller
673/// verify it got a reprint rather than a re-solve. `unprintable_title_chars` is normally
674/// empty and names the characters of `pdf.title` the card font could not draw when it is not
675/// — the card still printed, with a visible stand-in for each of them, but a caller that
676/// accepts any card name should warn rather than hand over an untitled card. A card too big
677/// to print is refused with
678/// `resource_limit` — on its row/page count first (`card_service::MAX_PDF_ROWS` /
679/// `MAX_PDF_PAGES`), and on [`MAX_PDF_BYTES`] as the backstop.
680///
681/// Other errors follow the sibling card commands exactly: a malformed payload is
682/// `invalid_request`, anything the service rejects (including an out-of-band
683/// `pdf.font_scale`) is `command_failed` with the service's own message.
684#[cfg(feature = "pdf")]
685fn run_card_pdf(inner: &Value) -> String {
686    use crate::card_service::CardServiceError;
687
688    if inner.is_null() {
689        return error(
690            BridgeErrorCode::InvalidRequest,
691            "'card.pdf' requires a request payload (card v1 document)",
692            None,
693        );
694    }
695    let mut payload = inner.clone();
696    let stored_value = payload
697        .as_object_mut()
698        .and_then(|object| object.remove(STORED_CARD_KEY))
699        .filter(|value| !value.is_null());
700    let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
701        Ok(request) => request,
702        Err(err) => {
703            return error(
704                BridgeErrorCode::InvalidRequest,
705                format!("card.pdf request rejected: {err}"),
706                None,
707            )
708        }
709    };
710    let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
711        Some(value) => match serde_json::from_value(value) {
712            Ok(stored) => Some(stored),
713            Err(err) => {
714                return error(
715                    BridgeErrorCode::InvalidRequest,
716                    format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
717                    None,
718                )
719            }
720        },
721        None => None,
722    };
723
724    let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
725        Ok(card) => card,
726        // A card too large to print is a resource refusal, not a command failure: same code
727        // the byte cap below reports, so a caller has one condition to handle.
728        Err(err @ CardServiceError::TooLarge(_)) => {
729            return error(
730                BridgeErrorCode::ResourceLimit,
731                format!("card.pdf refused: {err}"),
732                None,
733            )
734        }
735        Err(err) => {
736            return error(
737                BridgeErrorCode::CommandFailed,
738                format!("card.pdf failed: {err}"),
739                None,
740            )
741        }
742    };
743    let byte_length = card.pdf_bytes.len();
744    if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
745        return envelope;
746    }
747    let mut response = json!({
748        "pdf_base64": encode_base64(&card.pdf_bytes),
749        "byte_length": byte_length,
750        "page_count": card.page_count,
751        "row_count": card.row_count,
752        "kind": crate::card_service::PDF_CARD_KIND,
753        "source": card.source.as_str(),
754        "unprintable_title_chars": card.unprintable_title_chars,
755    });
756    // MBA-1477: additive, and spelled exactly as `card.range_table`'s own block, so an app
757    // that already reads a truncation off the on-screen card reads the printed one the same
758    // way. Absent on a card that reached every row it asked for, which is every card the
759    // previous shape could describe at all — a truncated document used to come back here
760    // indistinguishable from a complete one, with only a smaller `row_count` to notice.
761    if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
762        object.insert(
763            "truncation".to_string(),
764            json!({
765                "requested_end": truncation.requested_end,
766                "last_row": truncation.last_row,
767                "reach": truncation.reach,
768            }),
769        );
770    }
771    success("card.pdf", response)
772}
773
774/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf` and
775/// `profile.export_a7p` — hence the two-feature gate, which must list every feature that
776/// has a binary to hand out or the function goes missing from exactly the build that
777/// needs it.
778///
779/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
780/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
781/// direct base64 dependency
782/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
783/// all thirteen release targets.
784#[cfg(any(feature = "pdf", feature = "profile-export"))]
785fn encode_base64(bytes: &[u8]) -> String {
786    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
787    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
788    for chunk in bytes.chunks(3) {
789        let triple = (u32::from(chunk[0]) << 16)
790            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
791            | u32::from(chunk.get(2).copied().unwrap_or(0));
792        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
793        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
794        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
795        out.push(if chunk.len() > 1 {
796            char::from(ALPHABET[(triple >> 6) as usize & 63])
797        } else {
798            '='
799        });
800        out.push(if chunk.len() > 2 {
801            char::from(ALPHABET[triple as usize & 63])
802        } else {
803            '='
804        });
805    }
806    out
807}
808
809/// Wrap a command's own typed error envelope losslessly in `details`.
810fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
811    let details = serde_json::to_value(typed).ok();
812    error(BridgeErrorCode::CommandFailed, message, details)
813}
814
815/// Shared decode for the two profile document commands: the inner request IS a
816/// [`crate::profile::ProfileData`] JSON document (the exact schema of
817/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
818/// tolerated, exactly as the CLI loads it).
819fn decode_profile_document(
820    inner: &Value,
821    command: &'static str,
822) -> Result<crate::profile::ProfileData, String> {
823    if inner.is_null() {
824        return Err(error(
825            BridgeErrorCode::InvalidRequest,
826            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
827            None,
828        ));
829    }
830    serde_json::from_value(inner.clone()).map_err(|err| {
831        error(
832            BridgeErrorCode::InvalidRequest,
833            format!("{command} request is not a ProfileData document: {err}"),
834            None,
835        )
836    })
837}
838
839/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
840/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
841/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
842/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
843/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
844/// engine> }` — `valid` is simply `warnings.is_empty()`.
845fn run_profile_validate(inner: &Value) -> String {
846    let profile = match decode_profile_document(inner, "profile.validate") {
847        Ok(profile) => profile,
848        Err(envelope) => return envelope,
849    };
850    let warnings = profile.validation_warnings();
851    match serde_json::to_value(&profile) {
852        Ok(normalized) => success(
853            "profile.validate",
854            json!({
855                "valid": warnings.is_empty(),
856                "warnings": warnings,
857                "normalized": normalized,
858            }),
859        ),
860        Err(err) => error(
861            BridgeErrorCode::InternalError,
862            format!("failed to serialize normalized profile: {err}"),
863            None,
864        ),
865    }
866}
867
868/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
869/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
870/// engine version (unknown keys are tolerated on input and dropped on output; defaults
871/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
872/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
873fn run_profile_normalize(inner: &Value) -> String {
874    let profile = match decode_profile_document(inner, "profile.normalize") {
875        Ok(profile) => profile,
876        Err(envelope) => return envelope,
877    };
878    match serde_json::to_value(&profile) {
879        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
880        Err(err) => error(
881            BridgeErrorCode::InternalError,
882            format!("failed to serialize normalized profile: {err}"),
883            None,
884        ),
885    }
886}
887
888/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
889#[cfg(not(target_arch = "wasm32"))]
890#[derive(Debug, Deserialize)]
891#[serde(deny_unknown_fields)]
892struct Bc5dInfoRequest {
893    path: String,
894}
895
896/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
897/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
898/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
899/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
900///
901/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
902/// metadata the header carries (format version, caliber, generator API version,
903/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
904/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
905/// a human-readable message (`invalid_request` when the payload itself is malformed).
906///
907/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
908/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
909/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
910/// therefore pre-check a downloaded table itself with
911/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
912/// message instead of provoking the `command_failed`. This command deliberately does NOT
913/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
914/// enforce the match.
915#[cfg(not(target_arch = "wasm32"))]
916fn run_bc5d_info(inner: &Value) -> String {
917    if inner.is_null() {
918        return error(
919            BridgeErrorCode::InvalidRequest,
920            "'bc5d.info' requires a request payload ({\"path\": ...})",
921            None,
922        );
923    }
924    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
925        Ok(request) => request,
926        Err(err) => {
927            return error(
928                BridgeErrorCode::InvalidRequest,
929                format!("bc5d.info request rejected: {err}"),
930                None,
931            )
932        }
933    };
934
935    let table =
936        match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
937            Ok(table) => table,
938            Err(err) => {
939                return error(
940                    BridgeErrorCode::CommandFailed,
941                    format!("bc5d.info: not a usable BC5D table: {err}"),
942                    None,
943                )
944            }
945        };
946
947    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
948    let (weight_lo, weight_hi) = table.weight_range();
949    let (vel_lo, vel_hi) = table.velocity_range();
950    success(
951        "bc5d.info",
952        json!({
953            // Reaching here means the magic, format version, dimensions, AND the stored
954            // CRC32 all checked out — crc_ok is not a separate weaker probe.
955            "valid": true,
956            "crc_ok": true,
957            "format_version": table.version(),
958            "caliber": table.caliber(),
959            // The integer the caliber guard actually compares (see the doc comment).
960            "caliber_key": table.caliber_key(),
961            "api_version": table.api_version(),
962            "generated_timestamp": table.timestamp(),
963            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
964            "bins": {
965                "weight": weight,
966                "bc": bc,
967                "muzzle_velocity": muzzle_vel,
968                "current_velocity": current_vel,
969                "drag_types": drag_types,
970            },
971            "total_cells": table.total_cells(),
972            "weight_range_grains": [weight_lo, weight_hi],
973            "velocity_range_fps": [vel_lo, vel_hi],
974        }),
975    )
976}
977
978/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
979/// are a few KiB; this exists purely as a resource bound (the request envelope's own
980/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
981#[cfg(feature = "profile-import")]
982pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
983
984/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
985/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
986/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
987/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
988/// MD5 envelope mismatch instead of importing with a warning.
989#[cfg(feature = "profile-import")]
990#[derive(Debug, Deserialize)]
991#[serde(deny_unknown_fields)]
992struct ProfileImportA7pRequest {
993    a7p_base64: String,
994    #[serde(default)]
995    zero_click: Option<String>,
996    #[serde(default)]
997    strict: bool,
998}
999
1000/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
1001/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
1002/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
1003/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
1004/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
1005/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
1006/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
1007/// structured form). The profile name is derived from the file (sanitized); renaming is
1008/// the caller's business — there is no name override here.
1009#[cfg(feature = "profile-import")]
1010fn run_profile_import_a7p(inner: &Value) -> String {
1011    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1012
1013    if inner.is_null() {
1014        return error(
1015            BridgeErrorCode::InvalidRequest,
1016            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
1017            None,
1018        );
1019    }
1020    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
1021        Ok(request) => request,
1022        Err(err) => {
1023            return error(
1024                BridgeErrorCode::InvalidRequest,
1025                format!("profile.import_a7p request rejected: {err}"),
1026                None,
1027            )
1028        }
1029    };
1030
1031    let zero_click = match request.zero_click.as_deref() {
1032        Some(raw) => match crate::adjustment::parse_click_value(raw) {
1033            Ok(click) => Some(click),
1034            Err(err) => {
1035                return error(
1036                    BridgeErrorCode::InvalidRequest,
1037                    format!("profile.import_a7p zero_click: {err}"),
1038                    None,
1039                )
1040            }
1041        },
1042        None => None,
1043    };
1044
1045    let bytes = match decode_base64(&request.a7p_base64) {
1046        Ok(bytes) => bytes,
1047        Err(err) => {
1048            return error(
1049                BridgeErrorCode::InvalidRequest,
1050                format!("profile.import_a7p a7p_base64: {err}"),
1051                None,
1052            )
1053        }
1054    };
1055    if bytes.len() > MAX_A7P_DECODED_BYTES {
1056        return error(
1057            BridgeErrorCode::ResourceLimit,
1058            format!(
1059                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
1060                bytes.len()
1061            ),
1062            None,
1063        );
1064    }
1065
1066    let doc = match parse_a7p(&bytes) {
1067        Ok(doc) => doc,
1068        Err(err) => {
1069            return error(
1070                BridgeErrorCode::CommandFailed,
1071                format!("not a usable .a7p file: {err}"),
1072                None,
1073            )
1074        }
1075    };
1076    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
1077    // a warning in the report, also exactly as the CLI behaves.
1078    if request.strict {
1079        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
1080            return error(
1081                BridgeErrorCode::CommandFailed,
1082                format!(
1083                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
1084                ),
1085                None,
1086            );
1087        }
1088    }
1089
1090    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1091        Ok(outcome) => outcome,
1092        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1093    };
1094    let unknown_fields: Vec<Value> = doc
1095        .unknown_fields
1096        .iter()
1097        .map(|u| json!({ "context": u.context, "number": u.number }))
1098        .collect();
1099    match serde_json::to_value(&outcome.profile) {
1100        Ok(profile) => success(
1101            "profile.import_a7p",
1102            json!({
1103                "profile": profile,
1104                "warnings": outcome.report.warnings,
1105                "mapped": outcome.report.mapped,
1106                "unmapped": outcome.report.unmapped,
1107                "unknown_fields": unknown_fields,
1108            }),
1109        ),
1110        Err(err) => error(
1111            BridgeErrorCode::InternalError,
1112            format!("failed to serialize imported profile: {err}"),
1113            None,
1114        ),
1115    }
1116}
1117
1118/// `profile.export_a7p`: encode a ProfileData document as an ArcherBC2 `.a7p` file,
1119/// with the full account of what could not travel. Request payload is the ProfileData
1120/// document itself, exactly like `profile.validate`/`profile.normalize`.
1121///
1122/// Result: `{ "a7p_base64": "..", "byte_length": N, "carried_fields": [..],
1123/// "not_carried": [{field, populated, value, reason}, ..], "dropped_fields": [..],
1124/// "warnings": [..] }`.
1125///
1126/// `not_carried` is the reason this command has a shape of its own rather than
1127/// returning bare bytes, and it is NOT optional output. `.a7p` is a third-party format
1128/// built around one rifle, one load and one device; a saved profile holds a good deal it
1129/// has no slot for, and an app that shows a shooter "exported" without showing them what
1130/// their friend will not receive has told them something untrue. `not_carried` lists
1131/// every unsupported field unconditionally, with `populated` saying whether THIS profile
1132/// actually lost anything there, and `dropped_fields` is the pre-filtered subset that
1133/// did — the list to put in front of a person. `carried_fields` is the other half of the
1134/// same partition, so a caller can account for the whole document.
1135///
1136/// Errors carry a structured `reason` in `error.details` under the `true.*` convention
1137/// (`error.code` stays `command_failed`): `units`, `drag_model`, or `field` plus the
1138/// offending field name.
1139#[cfg(feature = "profile-export")]
1140fn run_profile_export_a7p(inner: &Value) -> String {
1141    use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
1142
1143    let profile = match decode_profile_document(inner, "profile.export_a7p") {
1144        Ok(profile) => profile,
1145        Err(envelope) => return envelope,
1146    };
1147    let export = match export_a7p(&profile) {
1148        Ok(export) => export,
1149        Err(err) => {
1150            let details = match &err {
1151                A7pExportError::Units(_) => json!({ "reason": "units" }),
1152                A7pExportError::DragModel(model) => {
1153                    json!({ "reason": "drag_model", "drag_model": model })
1154                }
1155                A7pExportError::Field { field, .. } => {
1156                    json!({ "reason": "field", "field": field })
1157                }
1158            };
1159            return error(
1160                BridgeErrorCode::CommandFailed,
1161                err.to_string(),
1162                Some(details),
1163            );
1164        }
1165    };
1166    let not_carried = match serde_json::to_value(&export.not_carried) {
1167        Ok(value) => value,
1168        Err(err) => {
1169            return error(
1170                BridgeErrorCode::InternalError,
1171                format!("failed to serialize the not-carried list: {err}"),
1172                None,
1173            )
1174        }
1175    };
1176    success(
1177        "profile.export_a7p",
1178        json!({
1179            "a7p_base64": encode_base64(&export.bytes),
1180            "byte_length": export.bytes.len(),
1181            "carried_fields": CARRIED_FIELDS,
1182            "not_carried": not_carried,
1183            "dropped_fields": export.dropped_fields(),
1184            "warnings": export.warnings,
1185        }),
1186    )
1187}
1188
1189/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
1190///
1191/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
1192/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
1193/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
1194/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
1195/// anywhere but as final padding, and lengths of form 4n+1.
1196#[cfg(feature = "profile-import")]
1197fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1198    fn sextet(c: u8) -> Result<u32, String> {
1199        match c {
1200            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1201            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1202            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1203            b'+' => Ok(62),
1204            b'/' => Ok(63),
1205            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1206        }
1207    }
1208    let bytes = input.as_bytes();
1209    let data = match bytes {
1210        [rest @ .., b'=', b'='] => rest,
1211        [rest @ .., b'='] => rest,
1212        _ => bytes,
1213    };
1214    if data.contains(&b'=') {
1215        return Err("'=' is only valid as trailing padding".to_string());
1216    }
1217    if data.len() % 4 == 1 {
1218        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1219    }
1220    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1221    let mut acc: u32 = 0;
1222    let mut bits: u32 = 0;
1223    for &c in data {
1224        acc = (acc << 6) | sextet(c)?;
1225        bits += 6;
1226        if bits >= 8 {
1227            bits -= 8;
1228            out.push((acc >> bits) as u8);
1229        }
1230    }
1231    Ok(out)
1232}
1233
1234// ---------------------------------------------------------------------------
1235// reticle.* — MBA-1558
1236// ---------------------------------------------------------------------------
1237
1238/// How a request names the reticle it is asking about: either the full
1239/// [`crate::reticle::ReticleDescription`] inline, or a generator to build one from.
1240///
1241/// Exactly one, and the requests below check that rather than silently preferring one —
1242/// a caller that sends both has a bug, and picking a winner for them hides it.
1243#[derive(Debug, Deserialize)]
1244#[serde(tag = "kind", rename_all = "snake_case")]
1245enum ReticleGenerator {
1246    /// [`crate::reticle::ReticleDescription::mil_grid`].
1247    MilGrid { spacing_mil: f64, extent_mil: f64 },
1248    /// [`crate::reticle::ReticleDescription::tree`].
1249    Tree {
1250        rows: usize,
1251        row_spacing_mil: f64,
1252        spread_step_mil: f64,
1253    },
1254    /// [`crate::reticle::ReticleDescription::bdc_from_drops`]. Pairs are
1255    /// `[range_metres, drop_mil]`, and the generator labels each mark `"<range> m"` —
1256    /// the range unit is the wire's, not the shooter's, and an app showing those labels
1257    /// converts them itself.
1258    Bdc { drops: Vec<(f64, f64)> },
1259}
1260
1261/// Optional focal-plane overrides for a GENERATED reticle.
1262///
1263/// Every generator returns FFP with `reference_magnification` 1.0, and the generator docs
1264/// say callers wanting SFP "set those two fields afterwards". On the bridge there is no
1265/// afterwards — the caller never holds the struct — so the overrides have to travel with
1266/// the request or the generator path could only ever produce FFP reticles, which is half
1267/// the optics on the market.
1268#[derive(Debug, Default, Deserialize)]
1269#[serde(deny_unknown_fields)]
1270struct ReticlePlaneOverride {
1271    #[serde(default)]
1272    focal_plane: Option<crate::reticle::FocalPlane>,
1273    #[serde(default)]
1274    reference_magnification: Option<f64>,
1275}
1276
1277/// Build the description a request is about, from whichever source it supplied.
1278///
1279/// `Err` is a finished error envelope, matching how the other commands' helpers report.
1280fn resolve_reticle(
1281    command: &'static str,
1282    reticle: Option<crate::reticle::ReticleDescription>,
1283    generator: Option<ReticleGenerator>,
1284    catalog: Option<String>,
1285    plane: &ReticlePlaneOverride,
1286) -> Result<crate::reticle::ReticleDescription, String> {
1287    use crate::reticle::ReticleDescription;
1288
1289    // Exactly one source. Counted rather than matched pair-by-pair, so adding a fourth
1290    // source later cannot quietly reintroduce a precedence rule.
1291    let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1292        .iter()
1293        .filter(|named| **named)
1294        .count();
1295    if named > 1 {
1296        return Err(error(
1297            BridgeErrorCode::InvalidRequest,
1298            format!(
1299                "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1300                 supplying more than one is a caller bug rather than a preference"
1301            ),
1302            None,
1303        ));
1304    }
1305
1306    // Resolved before the match below so a catalog id reaches it as a full description.
1307    let reticle = match (reticle, catalog) {
1308        (reticle, None) => reticle,
1309        (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1310            Some(Ok(built)) => Some(built),
1311            Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1312            // Not a default: a caller asking for a specific reticle and silently getting a
1313            // different one would be shown holds for glass they are not looking through.
1314            None => {
1315                return Err(error(
1316                    BridgeErrorCode::InvalidRequest,
1317                    format!(
1318                        "{command}: no catalog reticle with id {id:?}; \
1319                         'reticle.catalog' lists what this build has"
1320                    ),
1321                    None,
1322                ))
1323            }
1324        },
1325    };
1326
1327    let mut described = match (reticle, generator) {
1328        (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1329        (None, None) => {
1330            return Err(error(
1331                BridgeErrorCode::InvalidRequest,
1332                format!(
1333                    "{command} requires one of 'reticle' (a full description), \
1334                     'generator' or 'catalog'"
1335                ),
1336                None,
1337            ))
1338        }
1339        (Some(reticle), None) => {
1340            if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1341                return Err(error(
1342                    BridgeErrorCode::InvalidRequest,
1343                    format!(
1344                        "{command}: 'focal_plane' and 'reference_magnification' override a \
1345                         GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1346                    ),
1347                    None,
1348                ));
1349            }
1350            reticle
1351        }
1352        (None, Some(generator)) => {
1353            let built = match generator {
1354                ReticleGenerator::MilGrid {
1355                    spacing_mil,
1356                    extent_mil,
1357                } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1358                ReticleGenerator::Tree {
1359                    rows,
1360                    row_spacing_mil,
1361                    spread_step_mil,
1362                } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1363                ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1364            };
1365            match built {
1366                Ok(built) => built,
1367                Err(err) => return Err(reticle_error_envelope(command, &err)),
1368            }
1369        }
1370    };
1371
1372    if let Some(focal_plane) = plane.focal_plane {
1373        described.focal_plane = focal_plane;
1374    }
1375    if let Some(reference_magnification) = plane.reference_magnification {
1376        described.reference_magnification = reference_magnification;
1377    }
1378    Ok(described)
1379}
1380
1381/// A [`crate::reticle::ReticleError`] as this family's structured error envelope.
1382///
1383/// Follows the `true.*` convention established by `true.dsf`: `error.code` stays
1384/// `command_failed`, and a stable machine-readable `reason` rides in `error.details`
1385/// alongside the human message, so a front end can render its own wording without
1386/// parsing prose. The reasons are the error's own variants, which is what makes them
1387/// stable — a new variant is a new reason, never a re-spelling of an old one.
1388fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1389    use crate::reticle::ReticleError;
1390
1391    let mut details = json!({ "reason": match err {
1392        ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1393        ReticleError::NonPositiveReferenceMagnification { .. } => {
1394            "non_positive_reference_magnification"
1395        }
1396        ReticleError::NoMarks => "no_marks",
1397        ReticleError::TooManyMarks { .. } => "too_many_marks",
1398        ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1399        ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1400        ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1401        ReticleError::InvalidSpec(_) => "invalid_spec",
1402    }});
1403
1404    // The numbers the variant carries, so a caller can point at the offending input
1405    // without re-parsing the sentence.
1406    match err {
1407        ReticleError::NonPositiveMagnification { magnification } => {
1408            details["magnification"] = json!(magnification);
1409        }
1410        ReticleError::NonPositiveReferenceMagnification {
1411            reference_magnification,
1412        } => {
1413            details["reference_magnification"] = json!(reference_magnification);
1414        }
1415        ReticleError::TooManyMarks { count, max } => {
1416            details["count"] = json!(count);
1417            details["max"] = json!(max);
1418        }
1419        ReticleError::NonFiniteMark { index } => {
1420            details["index"] = json!(index);
1421        }
1422        ReticleError::InvalidGeneratorParameter {
1423            parameter,
1424            value,
1425            rule,
1426        } => {
1427            details["parameter"] = json!(parameter);
1428            details["value"] = json!(value);
1429            details["rule"] = json!(rule);
1430        }
1431        ReticleError::NoMarks
1432        | ReticleError::NonFiniteHold { .. }
1433        | ReticleError::InvalidSpec(_) => {}
1434    }
1435    details["command"] = json!(command);
1436
1437    error(
1438        BridgeErrorCode::CommandFailed,
1439        err.to_string(),
1440        Some(details),
1441    )
1442}
1443
1444/// The nearest mark a hold landed by, reported so a caller does not index back into the
1445/// description and re-apply the scale itself.
1446///
1447/// BOTH positions are given because they answer different questions and are not the same
1448/// number on an SFP optic: `nominal` is the mark as authored (what a reticle diagram
1449/// prints), `true_angular` is where it actually sits at this magnification (what the hold
1450/// is measured against).
1451fn nearest_mark_value(
1452    reticle: &crate::reticle::ReticleDescription,
1453    hold: &crate::reticle::ReticleHold,
1454) -> Value {
1455    match hold
1456        .nearest_mark
1457        .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1458    {
1459        Some((index, mark)) => json!({
1460            "index": index,
1461            "kind": mark.kind.as_str(),
1462            "label": mark.label,
1463            "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1464            "true_angular": {
1465                "down_mil": mark.down_mil * hold.mark_scale,
1466                "right_mil": mark.right_mil * hold.mark_scale,
1467            },
1468        }),
1469        None => Value::Null,
1470    }
1471}
1472
1473/// `reticle.catalog`: the named reticles this build can produce.
1474///
1475/// Result: `{ "reticles": [{ "id", "display_name", "source", "notes", "mark_count" }, ..] }`.
1476/// An app populates a picker from this rather than hardcoding ids, so a reticle added to a
1477/// later engine appears without an app release — and one REMOVED does not leave a picker
1478/// offering something the engine will refuse.
1479///
1480/// `source` is carried onto the wire deliberately. A wrong subtension is a hold that is
1481/// wrong by a mark and looks entirely normal, so the provenance that makes it traceable
1482/// belongs where the person looking at the reticle can see it, not only in the crate.
1483fn run_reticle_catalog() -> String {
1484    let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1485        .into_iter()
1486        .map(|entry| {
1487            // Listed entries are built by `by_id` under test, so this cannot be the place a
1488            // caller learns an entry is broken; report what is known rather than failing the
1489            // whole listing over one bad row.
1490            let mark_count = crate::reticle_catalog::by_id(entry.id)
1491                .and_then(|built| built.ok())
1492                .map(|reticle| reticle.marks.len());
1493            json!({
1494                "id": entry.id,
1495                "display_name": entry.display_name,
1496                "source": entry.source,
1497                "notes": entry.notes,
1498                "mark_count": mark_count,
1499            })
1500        })
1501        .collect();
1502    success("reticle.catalog", json!({ "reticles": reticles }))
1503}
1504
1505#[derive(Debug, Deserialize)]
1506#[serde(deny_unknown_fields)]
1507struct ReticleDescribeRequest {
1508    #[serde(default)]
1509    reticle: Option<crate::reticle::ReticleDescription>,
1510    #[serde(default)]
1511    generator: Option<ReticleGenerator>,
1512    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1513    /// and the one an app picker uses.
1514    #[serde(default)]
1515    catalog: Option<String>,
1516    #[serde(default)]
1517    focal_plane: Option<crate::reticle::FocalPlane>,
1518    #[serde(default)]
1519    reference_magnification: Option<f64>,
1520    /// When present, the result also carries every mark's TRUE angular position at this
1521    /// magnification. Absent means "just the description".
1522    #[serde(default)]
1523    magnification: Option<f64>,
1524}
1525
1526/// `reticle.describe`: resolve a reticle — supplied or generated — and report it.
1527///
1528/// Result: `{ "reticle": <ReticleDescription>, "focal_plane": "FFP"|"SFP",
1529/// "magnification_dependent": bool, "mark_count": n, "scaled": {...}|null }`.
1530///
1531/// This is the command a picker and a drawing are built on. `scaled` is present only when
1532/// the request supplied a magnification, and carries `mark_scale` plus the marks in true
1533/// angular space — for an FFP reticle that is the nominal marks and a scale of exactly
1534/// 1.0, which is worth returning anyway so a caller has one code path.
1535fn run_reticle_describe(inner: &Value) -> String {
1536    if inner.is_null() {
1537        return error(
1538            BridgeErrorCode::InvalidRequest,
1539            "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1540            None,
1541        );
1542    }
1543    let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1544        Ok(request) => request,
1545        Err(err) => {
1546            return error(
1547                BridgeErrorCode::InvalidRequest,
1548                format!("reticle.describe request rejected: {err}"),
1549                None,
1550            )
1551        }
1552    };
1553
1554    let plane = ReticlePlaneOverride {
1555        focal_plane: request.focal_plane,
1556        reference_magnification: request.reference_magnification,
1557    };
1558    let reticle = match resolve_reticle(
1559        "reticle.describe",
1560        request.reticle,
1561        request.generator,
1562        request.catalog,
1563        &plane,
1564    ) {
1565        Ok(reticle) => reticle,
1566        Err(envelope) => return envelope,
1567    };
1568
1569    // Validate even when no magnification was asked for: a description that cannot be
1570    // held on is not a description worth handing back as if it were usable.
1571    if let Err(err) = reticle.validate() {
1572        return reticle_error_envelope("reticle.describe", &err);
1573    }
1574
1575    let scaled = match request.magnification {
1576        Some(magnification) => match reticle.scaled_marks(magnification) {
1577            Ok(marks) => json!({
1578                "magnification": magnification,
1579                "mark_scale": reticle.mark_scale(magnification),
1580                "marks": marks
1581                    .iter()
1582                    .map(|mark| json!({
1583                        "down_mil": mark.down_mil,
1584                        "right_mil": mark.right_mil,
1585                    }))
1586                    .collect::<Vec<_>>(),
1587            }),
1588            Err(err) => return reticle_error_envelope("reticle.describe", &err),
1589        },
1590        None => Value::Null,
1591    };
1592
1593    match serde_json::to_value(&reticle) {
1594        Ok(described) => success(
1595            "reticle.describe",
1596            json!({
1597                "reticle": described,
1598                "focal_plane": reticle.focal_plane.label(),
1599                "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1600                "mark_count": reticle.marks.len(),
1601                "scaled": scaled,
1602            }),
1603        ),
1604        Err(err) => error(
1605            BridgeErrorCode::InternalError,
1606            format!("failed to serialize reticle description: {err}"),
1607            None,
1608        ),
1609    }
1610}
1611
1612#[derive(Debug, Deserialize)]
1613#[serde(deny_unknown_fields)]
1614struct ReticleHoldRequest {
1615    #[serde(default)]
1616    reticle: Option<crate::reticle::ReticleDescription>,
1617    #[serde(default)]
1618    generator: Option<ReticleGenerator>,
1619    /// A `reticle.catalog` id. The third and simplest way to name a reticle,
1620    /// and the one an app picker uses.
1621    #[serde(default)]
1622    catalog: Option<String>,
1623    #[serde(default)]
1624    focal_plane: Option<crate::reticle::FocalPlane>,
1625    #[serde(default)]
1626    reference_magnification: Option<f64>,
1627    /// Angular drop below the line of sight, positive = below: the come-up the shooter
1628    /// would otherwise dial.
1629    drop_mil: f64,
1630    /// Angular wind deflection, positive = the bullet goes RIGHT.
1631    #[serde(default)]
1632    wind_mil: f64,
1633    /// The optic's CURRENT magnification. Required on every focal plane — an FFP hold does
1634    /// not depend on it, but zero magnification is not a physical optic and
1635    /// `hold_point_in_reticle` rejects it on both planes rather than masking a caller bug.
1636    magnification: f64,
1637}
1638
1639/// `reticle.hold`: which mark to hold on.
1640///
1641/// The command this family exists for. Given a firing solution already reduced to angles
1642/// (`drop_mil` / `wind_mil`) and the optic's current magnification, place it in the
1643/// reticle and report the nearest mark.
1644///
1645/// ⚠️ THE ANGLES ARE INPUTS, NOT A SOLVE. This command runs no physics — it is the mark
1646/// search around [`crate::reticle::hold_point_in_reticle`], which is why the module can
1647/// keep its no-physics property. A caller gets `drop_mil` and `wind_mil` from `solve` (or
1648/// a card row) and passes them in; nothing here re-derives them, and a stale angle in
1649/// produces a confident hold out.
1650///
1651/// Result: `{ "hold": <ReticleHold>, "nearest_mark": {...}|null }`. `hold.off_reticle`
1652/// is the field a UI must not ignore: true means the solution has run off the marked part
1653/// of the reticle and there is nothing honest to hold on.
1654fn run_reticle_hold(inner: &Value) -> String {
1655    if inner.is_null() {
1656        return error(
1657            BridgeErrorCode::InvalidRequest,
1658            "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1659            None,
1660        );
1661    }
1662    let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1663        Ok(request) => request,
1664        Err(err) => {
1665            return error(
1666                BridgeErrorCode::InvalidRequest,
1667                format!("reticle.hold request rejected: {err}"),
1668                None,
1669            )
1670        }
1671    };
1672
1673    let plane = ReticlePlaneOverride {
1674        focal_plane: request.focal_plane,
1675        reference_magnification: request.reference_magnification,
1676    };
1677    let reticle = match resolve_reticle(
1678        "reticle.hold",
1679        request.reticle,
1680        request.generator,
1681        request.catalog,
1682        &plane,
1683    ) {
1684        Ok(reticle) => reticle,
1685        Err(envelope) => return envelope,
1686    };
1687
1688    let hold = match crate::reticle::hold_point_in_reticle(
1689        request.drop_mil,
1690        request.wind_mil,
1691        request.magnification,
1692        &reticle,
1693    ) {
1694        Ok(hold) => hold,
1695        Err(err) => return reticle_error_envelope("reticle.hold", &err),
1696    };
1697
1698    let nearest = nearest_mark_value(&reticle, &hold);
1699    match serde_json::to_value(&hold) {
1700        Ok(hold_value) => success(
1701            "reticle.hold",
1702            json!({
1703                "hold": hold_value,
1704                "nearest_mark": nearest,
1705            }),
1706        ),
1707        Err(err) => error(
1708            BridgeErrorCode::InternalError,
1709            format!("failed to serialize reticle hold: {err}"),
1710            None,
1711        ),
1712    }
1713}
1714
1715/// Most holds `reticle.holds` will place in one call.
1716///
1717/// A sampled trajectory is the thing this exists for and runs to a hundred-odd rows; the
1718/// cap is far above that and exists so a malformed request cannot ask for unbounded work.
1719pub const MAX_RETICLE_HOLDS: usize = 4096;
1720
1721#[derive(Debug, Deserialize)]
1722#[serde(deny_unknown_fields)]
1723struct ReticleHoldsEntry {
1724    drop_mil: f64,
1725    #[serde(default)]
1726    wind_mil: f64,
1727}
1728
1729#[derive(Debug, Deserialize)]
1730#[serde(deny_unknown_fields)]
1731struct ReticleHoldsRequest {
1732    #[serde(default)]
1733    reticle: Option<crate::reticle::ReticleDescription>,
1734    #[serde(default)]
1735    generator: Option<ReticleGenerator>,
1736    #[serde(default)]
1737    catalog: Option<String>,
1738    #[serde(default)]
1739    focal_plane: Option<crate::reticle::FocalPlane>,
1740    #[serde(default)]
1741    reference_magnification: Option<f64>,
1742    magnification: f64,
1743    /// The firing solutions to place, in the caller's own order, which the result
1744    /// preserves.
1745    holds: Vec<ReticleHoldsEntry>,
1746}
1747
1748/// `reticle.holds`: [`run_reticle_hold`] for a whole table, in one call.
1749///
1750/// A holdover COLUMN needs a hold per row, and a sampled trajectory is a hundred-odd rows.
1751/// Done one at a time that is a hundred round trips through the FFI with a JSON encode and
1752/// decode each; done in the app it is the nearest-mark search re-implemented on two
1753/// platforms, which is the second spelling `AdjustmentConversion` exists to argue against.
1754/// So it is one call: the reticle is resolved and validated ONCE, and the search runs per
1755/// row inside the engine.
1756///
1757/// Result: `{ "mark_scale": s, "holds": [ <the same shape reticle.hold returns>, .. ] }`,
1758/// in request order. `mark_scale` is hoisted because it is a property of the reticle and
1759/// the magnification, identical for every row, and repeating it per row would invite a
1760/// caller to wonder when it might differ.
1761///
1762/// The whole request fails on the first unusable hold rather than returning a list with a
1763/// hole in it: a column that silently skips a row is worse than one that does not draw.
1764fn run_reticle_holds(inner: &Value) -> String {
1765    if inner.is_null() {
1766        return error(
1767            BridgeErrorCode::InvalidRequest,
1768            "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1769            None,
1770        );
1771    }
1772    let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1773        Ok(request) => request,
1774        Err(err) => {
1775            return error(
1776                BridgeErrorCode::InvalidRequest,
1777                format!("reticle.holds request rejected: {err}"),
1778                None,
1779            )
1780        }
1781    };
1782
1783    if request.holds.len() > MAX_RETICLE_HOLDS {
1784        return error(
1785            BridgeErrorCode::ResourceLimit,
1786            format!(
1787                "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1788                request.holds.len()
1789            ),
1790            None,
1791        );
1792    }
1793
1794    let plane = ReticlePlaneOverride {
1795        focal_plane: request.focal_plane,
1796        reference_magnification: request.reference_magnification,
1797    };
1798    let reticle = match resolve_reticle(
1799        "reticle.holds",
1800        request.reticle,
1801        request.generator,
1802        request.catalog,
1803        &plane,
1804    ) {
1805        Ok(reticle) => reticle,
1806        Err(envelope) => return envelope,
1807    };
1808
1809    let mut placed = Vec::with_capacity(request.holds.len());
1810    let mut scale = 1.0;
1811    for entry in &request.holds {
1812        let hold = match crate::reticle::hold_point_in_reticle(
1813            entry.drop_mil,
1814            entry.wind_mil,
1815            request.magnification,
1816            &reticle,
1817        ) {
1818            Ok(hold) => hold,
1819            // Defensive, and unreachable from the wire: a bad reticle and a non-positive
1820            // magnification are both settled before this loop, and the only error left is
1821            // a non-finite hold, which JSON cannot carry (serde_json refuses an
1822            // out-of-range literal, and infinity encodes as null). Kept because this is
1823            // also reachable from the Rust API, and because a silently skipped row would
1824            // be worse than a refused request.
1825            Err(err) => return reticle_error_envelope("reticle.holds", &err),
1826        };
1827        scale = hold.mark_scale;
1828        let nearest = nearest_mark_value(&reticle, &hold);
1829        match serde_json::to_value(&hold) {
1830            Ok(mut value) => {
1831                // Hoisted to the top level; see the doc comment.
1832                if let Some(object) = value.as_object_mut() {
1833                    object.remove("mark_scale");
1834                }
1835                placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1836            }
1837            Err(err) => {
1838                return error(
1839                    BridgeErrorCode::InternalError,
1840                    format!("failed to serialize reticle hold: {err}"),
1841                    None,
1842                )
1843            }
1844        }
1845    }
1846
1847    success(
1848        "reticle.holds",
1849        json!({ "mark_scale": scale, "holds": placed }),
1850    )
1851}
1852
1853/// Largest reticle document `reticle.import` will parse.
1854///
1855/// The envelope cap ([`MAX_REQUEST_BYTES`]) already bounds the whole request, but a
1856/// document-specific cap gives a caller a message naming the document rather than the
1857/// envelope, and bounds the parse before it starts. Both formats are TEXT and travel
1858/// inline — unlike `.a7p`, which is binary and needs base64 — so there is no inflation
1859/// factor to leave headroom for.
1860pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1861
1862#[derive(Debug, Deserialize)]
1863#[serde(rename_all = "snake_case")]
1864enum ReticleDocumentFormat {
1865    /// The Ventum JSON reticle spec (MBA-1440).
1866    Ventum,
1867    /// The third-party `.reticle` XML drawing format (MBA-1544).
1868    ReticleXml,
1869}
1870
1871#[derive(Debug, Deserialize)]
1872#[serde(deny_unknown_fields)]
1873struct ReticleImportRequest {
1874    format: ReticleDocumentFormat,
1875    /// The document itself, as text.
1876    document: String,
1877}
1878
1879/// `reticle.import`: turn a third-party reticle document into a
1880/// [`crate::reticle::ReticleDescription`].
1881///
1882/// Result: `{ "reticle": <ReticleDescription>, "format": "ventum"|"reticle_xml",
1883/// "report": {...} }`. The report is format-specific and is NOT decoration: both importers
1884/// drop elements they cannot turn into hold points, and a caller that ignores the report
1885/// shows a shooter a reticle missing marks their drawing had. The same obligation
1886/// `profile.import_a7p` discharges with its `unmapped` list.
1887fn run_reticle_import(inner: &Value) -> String {
1888    if inner.is_null() {
1889        return error(
1890            BridgeErrorCode::InvalidRequest,
1891            "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1892            None,
1893        );
1894    }
1895    let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1896        Ok(request) => request,
1897        Err(err) => {
1898            return error(
1899                BridgeErrorCode::InvalidRequest,
1900                format!("reticle.import request rejected: {err}"),
1901                None,
1902            )
1903        }
1904    };
1905
1906    if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1907        return error(
1908            BridgeErrorCode::ResourceLimit,
1909            format!(
1910                "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1911                request.document.len()
1912            ),
1913            None,
1914        );
1915    }
1916
1917    let (reticle, report, format) = match request.format {
1918        ReticleDocumentFormat::Ventum => {
1919            match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1920                Ok((reticle, report)) => (
1921                    reticle,
1922                    json!({
1923                        "dropped_elements": report.dropped_elements,
1924                        "dropped_element_types": report
1925                            .dropped_element_types
1926                            .iter()
1927                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1928                            .collect::<Vec<_>>(),
1929                        // Arcs are REPORTED, never imported as marks: a horseshoe is a
1930                        // shape a shooter indexes on, and which of its points counts as an
1931                        // aiming point is the caller's decision, not this bridge's. Apex
1932                        // and the two tips are the points the importer resolves.
1933                        "arcs": report
1934                            .arcs
1935                            .iter()
1936                            .map(|arc| json!({
1937                                "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1938                                "radius_mil": arc.radius_mil,
1939                                "start_degrees": arc.start_degrees,
1940                                "end_degrees": arc.end_degrees,
1941                                "sweep_degrees": arc.sweep_degrees,
1942                                "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1943                                "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1944                                "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1945                            }))
1946                            .collect::<Vec<_>>(),
1947                        // Both shortfalls the report declares about itself, carried rather
1948                        // than dropped: `arcs.len() + arcs_unresolved` is the arc tally, and
1949                        // `circle_repeats_unreadable` says the element count is low by an
1950                        // unknown amount.
1951                        "arcs_unresolved": report.arcs_unresolved,
1952                        "circle_repeats_unreadable": report.circle_repeats_unreadable,
1953                    }),
1954                    "ventum",
1955                ),
1956                Err(err) => return reticle_error_envelope("reticle.import", &err),
1957            }
1958        }
1959        ReticleDocumentFormat::ReticleXml => {
1960            match crate::reticle_document_import::import_reticle_document_with_report(
1961                &request.document,
1962            ) {
1963                Ok((reticle, report)) => (
1964                    reticle,
1965                    json!({
1966                        "drawing_elements_dropped": report.drawing_elements_dropped,
1967                        "dropped_element_tags": report
1968                            .dropped_element_tags
1969                            .iter()
1970                            .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1971                            .collect::<Vec<_>>(),
1972                        "canvas_mil": report
1973                            .canvas_mil
1974                            .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1975                        "zero_in_canvas_mil": report
1976                            .zero_in_canvas_mil
1977                            .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1978                    }),
1979                    "reticle_xml",
1980                ),
1981                Err(err) => return reticle_error_envelope("reticle.import", &err),
1982            }
1983        }
1984    };
1985
1986    match serde_json::to_value(&reticle) {
1987        Ok(imported) => success(
1988            "reticle.import",
1989            json!({
1990                "reticle": imported,
1991                "format": format,
1992                "mark_count": reticle.marks.len(),
1993                "report": report,
1994            }),
1995        ),
1996        Err(err) => error(
1997            BridgeErrorCode::InternalError,
1998            format!("failed to serialize imported reticle: {err}"),
1999            None,
2000        ),
2001    }
2002}
2003
2004#[cfg(test)]
2005mod tests {
2006    use super::*;
2007
2008    fn call(value: Value) -> Value {
2009        let raw = bridge_call(&value.to_string());
2010        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
2011    }
2012
2013    #[test]
2014    fn capabilities_reports_commands_and_versions() {
2015        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2016        assert_eq!(out["ok"], true);
2017        assert_eq!(out["api_version"], 1);
2018        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
2019        let commands: Vec<String> =
2020            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2021        assert!(commands.contains(&"solve".to_string()));
2022        assert!(commands.contains(&"meta.capabilities".to_string()));
2023    }
2024
2025    #[test]
2026    fn density_altitude_is_listed_right_after_solve() {
2027        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2028        let commands: Vec<String> =
2029            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2030        let solve = commands.iter().position(|c| c == "solve").unwrap();
2031        assert_eq!(
2032            commands.get(solve + 1).map(String::as_str),
2033            Some("atmosphere.density_altitude"),
2034            "{commands:?}"
2035        );
2036    }
2037
2038    #[test]
2039    fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
2040        let out = call(json!({
2041            "api_version": 1,
2042            "command": "atmosphere.density_altitude",
2043            "request": {"atmosphere": {
2044                "altitude_m": 0.0,
2045                "temperature_k": 288.15,
2046                "pressure_pa": 101325.0,
2047                "relative_humidity": 0.5,
2048            }},
2049        }));
2050        assert_eq!(out["ok"], true, "{out}");
2051        assert_eq!(out["command"], "atmosphere.density_altitude");
2052        let result = &out["result"];
2053        let faa = result["density_altitude"]["faa_rule"]["m"]
2054            .as_f64()
2055            .unwrap();
2056        let matched = result["density_altitude"]["density_matched"]["m"]
2057            .as_f64()
2058            .unwrap();
2059        assert!(faa.abs() < 1e-9, "{result}");
2060        assert!((matched - 33.10).abs() < 0.2, "{result}");
2061        let ft = result["density_altitude"]["density_matched"]["ft"]
2062            .as_f64()
2063            .unwrap();
2064        assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
2065        assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
2066        // Everything was supplied, so nothing was assumed.
2067        assert_eq!(result["assumptions"], json!([]), "{result}");
2068        assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
2069    }
2070
2071    #[test]
2072    fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
2073        let out = call(json!({
2074            "api_version": 1,
2075            "command": "atmosphere.density_altitude",
2076            "request": {"atmosphere": {}},
2077        }));
2078        assert_eq!(out["ok"], true, "{out}");
2079        let assumptions = out["result"]["assumptions"].as_array().unwrap();
2080        assert!(!assumptions.is_empty(), "{out}");
2081        for notice in assumptions {
2082            assert!(
2083                notice["path"]
2084                    .as_str()
2085                    .unwrap_or_default()
2086                    .starts_with("$.atmosphere."),
2087                "{notice}"
2088            );
2089        }
2090    }
2091
2092    #[test]
2093    fn density_altitude_without_payload_is_invalid_request() {
2094        let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
2095        assert_eq!(out["ok"], false);
2096        assert_eq!(out["error"]["code"], "invalid_request");
2097    }
2098
2099    #[test]
2100    fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
2101        // `temperature_c` is the likeliest mistake from a caller used to the imperial/metric
2102        // card commands; the SI schema must refuse it rather than silently default to 15 C,
2103        // and say where, exactly as `solve` would.
2104        let out = call(json!({
2105            "api_version": 1,
2106            "command": "atmosphere.density_altitude",
2107            "request": {"atmosphere": {"temperature_c": 30.0}},
2108        }));
2109        assert_eq!(out["ok"], false, "{out}");
2110        assert_eq!(out["error"]["code"], "command_failed");
2111        assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
2112        assert_eq!(
2113            out["error"]["details"]["error"]["path"],
2114            "$.atmosphere.temperature_c"
2115        );
2116    }
2117
2118    #[test]
2119    fn density_altitude_refuses_celsius_sent_as_kelvin() {
2120        let out = call(json!({
2121            "api_version": 1,
2122            "command": "atmosphere.density_altitude",
2123            "request": {"atmosphere": {"temperature_k": 15.0}},
2124        }));
2125        assert_eq!(out["ok"], false, "{out}");
2126        assert_eq!(out["error"]["code"], "command_failed");
2127        assert_eq!(
2128            out["error"]["details"]["error"]["path"],
2129            "$.atmosphere.temperature_k"
2130        );
2131    }
2132
2133    #[test]
2134    fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
2135        let out = call(json!({
2136            "api_version": 1,
2137            "command": "atmosphere.density_altitude",
2138            "request": {"atmosphere": {"relative_humidity": 50.0}},
2139        }));
2140        assert_eq!(out["ok"], false, "{out}");
2141        assert_eq!(out["error"]["code"], "command_failed");
2142        assert!(
2143            out["error"]["details"]
2144                .to_string()
2145                .contains("$.atmosphere.relative_humidity"),
2146            "{out}"
2147        );
2148    }
2149
2150    #[test]
2151    fn invalid_json_is_an_envelope_not_a_panic() {
2152        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
2153        assert_eq!(out["ok"], false);
2154        assert_eq!(out["error"]["code"], "invalid_json");
2155    }
2156
2157    #[test]
2158    fn unknown_envelope_field_is_rejected() {
2159        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
2160        assert_eq!(out["ok"], false);
2161        assert_eq!(out["error"]["code"], "invalid_json");
2162    }
2163
2164    #[test]
2165    fn unknown_command_lists_supported_ones() {
2166        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
2167        // can ever dispatch so this test means the same thing in every feature set.
2168        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
2169        assert_eq!(out["error"]["code"], "unknown_command");
2170        assert!(out["error"]["message"]
2171            .as_str()
2172            .unwrap()
2173            .contains("meta.capabilities"));
2174    }
2175
2176    #[test]
2177    fn wrong_api_version_is_rejected() {
2178        let out = call(json!({"api_version": 99, "command": "meta.version"}));
2179        assert_eq!(out["error"]["code"], "unsupported_api_version");
2180    }
2181
2182    #[test]
2183    fn oversize_request_is_a_resource_limit() {
2184        let big = format!(
2185            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
2186            "x".repeat(MAX_REQUEST_BYTES)
2187        );
2188        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
2189        assert_eq!(out["error"]["code"], "resource_limit");
2190    }
2191
2192    #[test]
2193    fn solve_without_payload_is_invalid_request() {
2194        let out = call(json!({"api_version": 1, "command": "solve"}));
2195        assert_eq!(out["error"]["code"], "invalid_request");
2196    }
2197
2198    #[test]
2199    fn profile_commands_without_payload_are_invalid_requests() {
2200        for command in ["profile.validate", "profile.normalize"] {
2201            let out = call(json!({"api_version": 1, "command": command}));
2202            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
2203            assert!(
2204                out["error"]["message"]
2205                    .as_str()
2206                    .unwrap()
2207                    .contains("ProfileData"),
2208                "{command}: {out}"
2209            );
2210        }
2211    }
2212
2213    #[test]
2214    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
2215        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2216        let commands: Vec<String> =
2217            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2218        assert!(commands.contains(&"profile.validate".to_string()));
2219        assert!(commands.contains(&"profile.normalize".to_string()));
2220        assert_eq!(
2221            commands.contains(&"profile.import_a7p".to_string()),
2222            cfg!(feature = "profile-import"),
2223            "profile.import_a7p must be listed exactly when compiled in"
2224        );
2225        assert_eq!(
2226            commands.contains(&"profile.export_a7p".to_string()),
2227            cfg!(feature = "profile-export"),
2228            "profile.export_a7p must be listed exactly when compiled in"
2229        );
2230        assert_eq!(
2231            commands.contains(&"bc5d.info".to_string()),
2232            cfg!(not(target_arch = "wasm32")),
2233            "bc5d.info must be listed exactly when the build has filesystem access"
2234        );
2235    }
2236
2237    #[cfg(not(target_arch = "wasm32"))]
2238    #[test]
2239    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
2240        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
2241        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2242
2243        let out = call(json!({
2244            "api_version": 1,
2245            "command": "bc5d.info",
2246            "request": {"path": "/nonexistent/bc5d_308.bin"}
2247        }));
2248        assert_eq!(out["error"]["code"], "command_failed", "{out}");
2249        assert!(
2250            out["error"]["message"]
2251                .as_str()
2252                .unwrap()
2253                .contains("not a usable BC5D table"),
2254            "{out}"
2255        );
2256    }
2257
2258    #[cfg(feature = "profile-import")]
2259    #[test]
2260    fn base64_decoder_round_trips_and_rejects_garbage() {
2261        // RFC 4648 test vectors.
2262        for (text, bytes) in [
2263            ("", &b""[..]),
2264            ("Zg==", b"f"),
2265            ("Zm8=", b"fo"),
2266            ("Zm9v", b"foo"),
2267            ("Zm9vYg==", b"foob"),
2268            ("Zm9vYmE=", b"fooba"),
2269            ("Zm9vYmFy", b"foobar"),
2270        ] {
2271            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
2272        }
2273        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
2274        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
2275        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
2276        assert!(
2277            decode_base64("Zm9v!").is_err(),
2278            "non-alphabet byte is rejected"
2279        );
2280    }
2281
2282    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
2283    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
2284    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
2285    #[test]
2286    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2287        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2288        let commands: Vec<String> =
2289            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2290        assert_eq!(
2291            commands.contains(&"card.pdf".to_string()),
2292            cfg!(feature = "pdf"),
2293            "card.pdf must be listed exactly when compiled in: {out}"
2294        );
2295        let features: Vec<String> =
2296            serde_json::from_value(out["result"]["features"].clone()).unwrap();
2297        assert_eq!(
2298            features.contains(&"pdf".to_string()),
2299            cfg!(feature = "pdf"),
2300            "the command list and the feature list must agree: {out}"
2301        );
2302    }
2303
2304    #[cfg(not(feature = "pdf"))]
2305    #[test]
2306    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2307        let out = call(json!({
2308            "api_version": 1,
2309            "command": "card.pdf",
2310            "request": {
2311                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2312                "mass": 175.0, "diameter": 0.308,
2313                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2314            }
2315        }));
2316        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2317    }
2318
2319    /// The `pdf` presentation block must survive a build that cannot render it: an app
2320    /// stores one request per card and replays it against `card.range_table` too, so a
2321    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
2322    #[test]
2323    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2324        let out = call(json!({
2325            "api_version": 1,
2326            "command": "card.range_table",
2327            "request": {
2328                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2329                "mass": 175.0, "diameter": 0.308,
2330                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2331                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2332            }
2333        }));
2334        assert_eq!(out["ok"], true, "{out}");
2335        assert_eq!(out["result"]["kind"], "range_table", "{out}");
2336    }
2337
2338    #[cfg(feature = "pdf")]
2339    #[test]
2340    fn card_pdf_without_payload_is_invalid_request() {
2341        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2342        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2343        assert!(
2344            out["error"]["message"]
2345                .as_str()
2346                .unwrap()
2347                .contains("card v1 document"),
2348            "{out}"
2349        );
2350    }
2351
2352    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
2353    /// would take tens of thousands of rows, so the predicate is tested directly — see
2354    /// `pdf_over_cap_error`'s own comment.
2355    #[cfg(feature = "pdf")]
2356    #[test]
2357    fn pdf_output_cap_refuses_only_over_the_limit() {
2358        assert!(pdf_over_cap_error(0, 0, 0).is_none());
2359        assert!(
2360            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2361            "a document exactly at the cap fits"
2362        );
2363        let envelope: Value =
2364            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2365                .unwrap();
2366        assert_eq!(envelope["ok"], false);
2367        assert_eq!(envelope["error"]["code"], "resource_limit");
2368        let message = envelope["error"]["message"].as_str().unwrap();
2369        assert!(message.contains("dope card"), "{envelope}");
2370        // What is true of the document, not advice about controls a saved card lacks.
2371        assert!(message.contains("6 rows"), "{envelope}");
2372        assert!(message.contains("1 pages"), "{envelope}");
2373        for absent in ["coarsen", "shorten"] {
2374            assert!(!message.contains(absent), "{envelope}");
2375        }
2376    }
2377
2378    #[cfg(feature = "pdf")]
2379    #[test]
2380    fn base64_encoder_matches_the_rfc_4648_vectors() {
2381        for (bytes, text) in [
2382            (&b""[..], ""),
2383            (b"f", "Zg=="),
2384            (b"fo", "Zm8="),
2385            (b"foo", "Zm9v"),
2386            (b"foob", "Zm9vYg=="),
2387            (b"fooba", "Zm9vYmE="),
2388            (b"foobar", "Zm9vYmFy"),
2389        ] {
2390            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2391        }
2392        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
2393        // width-mangle a high byte, which is most of a PDF's content.
2394        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2395        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2396        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2397    }
2398
2399    /// The encoder and the (profile-import) decoder must be inverses — the property that
2400    /// makes `pdf_base64` a lossless transport for a binary document.
2401    #[cfg(all(feature = "pdf", feature = "profile-import"))]
2402    #[test]
2403    fn base64_encode_decode_round_trips_arbitrary_bytes() {
2404        for len in 0..=32usize {
2405            let bytes: Vec<u8> = (0..len)
2406                .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2407                .collect();
2408            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2409            assert_eq!(decoded, bytes, "len {len}");
2410        }
2411    }
2412
2413    #[test]
2414    fn solve_with_bad_schema_carries_typed_details() {
2415        let out = call(json!({
2416            "api_version": 1,
2417            "command": "solve",
2418            "request": {"schema_version": 1, "unknown_field": true}
2419        }));
2420        assert_eq!(out["error"]["code"], "command_failed");
2421        // The solve-json envelope rides along losslessly.
2422        assert_eq!(out["error"]["details"]["status"], "error");
2423    }
2424
2425    // -----------------------------------------------------------------------
2426    // reticle.* — MBA-1558
2427    // -----------------------------------------------------------------------
2428
2429    /// A 0.5 mil grid out to 5 mil, as a full description rather than a generator, so a
2430    /// test can assert against marks it wrote itself.
2431    fn hash_reticle() -> Value {
2432        json!({
2433            "name": "test cross",
2434            "focal_plane": "ffp",
2435            "reference_magnification": 1.0,
2436            "marks": [
2437                {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2438                {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2439                {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2440                {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2441            ],
2442        })
2443    }
2444
2445    #[test]
2446    fn capabilities_lists_the_reticle_family() {
2447        // The whole point of MBA-1558: an app feature-detects through this list, so a
2448        // command that dispatches but is not listed is a command no app will call.
2449        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2450        let commands: Vec<String> =
2451            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2452        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2453            assert!(
2454                commands.contains(&command.to_string()),
2455                "{command} dispatches but meta.capabilities does not list it"
2456            );
2457        }
2458    }
2459
2460    #[test]
2461    fn hold_reports_the_nearest_mark_and_its_label() {
2462        let out = call(json!({
2463            "api_version": 1,
2464            "command": "reticle.hold",
2465            "request": {
2466                "reticle": hash_reticle(),
2467                "drop_mil": 2.1,
2468                "wind_mil": 0.0,
2469                "magnification": 10.0,
2470            },
2471        }));
2472        assert_eq!(out["ok"], true, "{out}");
2473        // The hold coordinates are the inputs: this command places a solution, it does
2474        // not compute one.
2475        assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2476        assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2477        assert_eq!(out["result"]["hold"]["off_reticle"], false);
2478        assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2479        assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2480        assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2481    }
2482
2483    #[test]
2484    fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2485        // The reason `magnification` is required on every plane. The same solution finds a
2486        // DIFFERENT mark on an SFP optic depending on the zoom ring, and an app that
2487        // ignored this would draw a confident hold on the wrong hash.
2488        let mut sfp = hash_reticle();
2489        sfp["focal_plane"] = json!("sfp");
2490        sfp["reference_magnification"] = json!(10.0);
2491
2492        let at_reference = call(json!({
2493            "api_version": 1, "command": "reticle.hold",
2494            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2495        }));
2496        assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2497        assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2498        assert_eq!(
2499            at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2500            2.0
2501        );
2502
2503        // Halve the magnification and every subtension doubles, so the 2 mil hash now
2504        // sits at 4 mil true and the 4 mil hash at 8.
2505        let zoomed_out = call(json!({
2506            "api_version": 1, "command": "reticle.hold",
2507            "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2508        }));
2509        assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2510        assert_eq!(
2511            zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2512            "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2513        );
2514        assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2515        // And the nominal position is unchanged, which is why both are reported.
2516        assert_eq!(
2517            zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2518            0.0
2519        );
2520
2521        // A mark whose coordinates are NOT zero, because the assertions above cannot tell
2522        // a scaled position from an unscaled one: 0.0 * 2.0 is 0.0. Proved by mutation --
2523        // dropping the scale from `true_angular` left every case above green.
2524        //
2525        // At 5x on a 10x-reference SFP reticle the 2 mil hash sits at 4 mil true, so a
2526        // 4 mil hold lands exactly on it. The two positions differ by the scale, which is
2527        // the whole reason both are reported.
2528        let on_the_scaled_hash = call(json!({
2529            "api_version": 1, "command": "reticle.hold",
2530            "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2531        }));
2532        assert_eq!(
2533            on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2534            "as authored, the mark is at 2 mil"
2535        );
2536        assert_eq!(
2537            on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2538            "at 5x it subtends 4 mil, which is what the hold is measured against"
2539        );
2540        assert_eq!(
2541            on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2542            "the hold lands exactly on it"
2543        );
2544    }
2545
2546    #[test]
2547    fn a_hold_off_the_marked_part_says_so() {
2548        // The field a UI must not ignore. 40 mil is far outside a reticle whose lowest
2549        // mark is at 4.
2550        let out = call(json!({
2551            "api_version": 1, "command": "reticle.hold",
2552            "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2553        }));
2554        assert_eq!(out["ok"], true);
2555        assert_eq!(out["result"]["hold"]["off_reticle"], true);
2556    }
2557
2558    #[test]
2559    fn describe_builds_from_a_generator_and_reports_the_plane() {
2560        let out = call(json!({
2561            "api_version": 1, "command": "reticle.describe",
2562            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2563        }));
2564        assert_eq!(out["ok"], true, "{out}");
2565        assert_eq!(out["result"]["focal_plane"], "FFP");
2566        assert_eq!(out["result"]["magnification_dependent"], false);
2567        assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2568        // No magnification asked for, so no scaled block — not an empty one.
2569        assert!(out["result"]["scaled"].is_null());
2570    }
2571
2572    #[test]
2573    fn a_generated_reticle_can_be_made_sfp() {
2574        // Every generator returns FFP and the library's answer is "set those two fields
2575        // afterwards". On the bridge there is no afterwards, so the overrides travel with
2576        // the request — without them the generator path could only ever make FFP.
2577        let out = call(json!({
2578            "api_version": 1, "command": "reticle.describe",
2579            "request": {
2580                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2581                "focal_plane": "sfp",
2582                "reference_magnification": 12.0,
2583                "magnification": 6.0,
2584            },
2585        }));
2586        assert_eq!(out["ok"], true, "{out}");
2587        assert_eq!(out["result"]["focal_plane"], "SFP");
2588        assert_eq!(out["result"]["magnification_dependent"], true);
2589        assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2590    }
2591
2592    #[test]
2593    fn a_bdc_ladder_carries_its_range_labels() {
2594        let out = call(json!({
2595            "api_version": 1, "command": "reticle.describe",
2596            "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2597        }));
2598        assert_eq!(out["ok"], true, "{out}");
2599        let marks = out["result"]["reticle"]["marks"]
2600            .as_array()
2601            .unwrap()
2602            .clone();
2603        let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2604        // The generator labels in METRES, which is the wire's unit and not the shooter's.
2605        assert_eq!(labels, vec!["300 m", "400 m"]);
2606    }
2607
2608    #[test]
2609    fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2610        // Supplying both is a caller bug. Picking a winner would hide it.
2611        let out = call(json!({
2612            "api_version": 1, "command": "reticle.hold",
2613            "request": {
2614                "reticle": hash_reticle(),
2615                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2616                "drop_mil": 1.0,
2617                "magnification": 10.0,
2618            },
2619        }));
2620        assert_eq!(out["ok"], false);
2621        assert_eq!(out["error"]["code"], "invalid_request");
2622        assert!(out["error"]["message"]
2623            .as_str()
2624            .unwrap()
2625            .contains("exactly one"));
2626    }
2627
2628    #[test]
2629    fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2630        let out = call(json!({
2631            "api_version": 1, "command": "reticle.hold",
2632            "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2633        }));
2634        assert_eq!(out["ok"], true, "{out}");
2635        // A mil-dot has a dot exactly 3 mil down, so the hold lands on it.
2636        assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2637        assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2638        assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2639    }
2640
2641    #[test]
2642    fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2643        // Handing back a different reticle would show a shooter holds for glass they are
2644        // not looking through.
2645        let out = call(json!({
2646            "api_version": 1, "command": "reticle.hold",
2647            "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2648        }));
2649        assert_eq!(out["ok"], false);
2650        assert_eq!(out["error"]["code"], "invalid_request");
2651        assert!(out["error"]["message"]
2652            .as_str()
2653            .unwrap()
2654            .contains("reticle.catalog"));
2655    }
2656
2657    #[test]
2658    fn naming_a_reticle_three_ways_is_refused_too() {
2659        // The count-based check exists so a third source could not reintroduce precedence.
2660        let out = call(json!({
2661            "api_version": 1, "command": "reticle.describe",
2662            "request": {
2663                "catalog": "mil-dot",
2664                "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2665            },
2666        }));
2667        assert_eq!(out["ok"], false);
2668        assert_eq!(out["error"]["code"], "invalid_request");
2669    }
2670
2671    #[test]
2672    fn the_catalog_lists_what_the_other_commands_will_accept() {
2673        // The contract an app picker is built on: every listed id must resolve, or the
2674        // picker offers something the engine refuses.
2675        let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2676        assert_eq!(out["ok"], true, "{out}");
2677        let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2678        assert!(!reticles.is_empty());
2679        for entry in reticles {
2680            let id = entry["id"].as_str().unwrap();
2681            // Provenance travels to the wire on purpose — see reticle_catalog's header.
2682            assert!(
2683                !entry["source"].as_str().unwrap().is_empty(),
2684                "{id} has no source"
2685            );
2686            assert!(
2687                !entry["notes"].as_str().unwrap().is_empty(),
2688                "{id} has no notes"
2689            );
2690            assert!(
2691                entry["mark_count"].as_u64().unwrap() > 0,
2692                "{id} has no marks"
2693            );
2694
2695            let held = call(json!({
2696                "api_version": 1, "command": "reticle.hold",
2697                "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2698            }));
2699            assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2700        }
2701    }
2702
2703    #[test]
2704    fn a_batch_of_holds_comes_back_in_request_order() {
2705        // The column this exists for: one call per table, not one per row.
2706        let out = call(json!({
2707            "api_version": 1, "command": "reticle.holds",
2708            "request": {
2709                "catalog": "mil-dot",
2710                "magnification": 10.0,
2711                "holds": [
2712                    {"drop_mil": 1.0},
2713                    {"drop_mil": 3.0, "wind_mil": 0.0},
2714                    {"drop_mil": 2.0},
2715                ],
2716            },
2717        }));
2718        assert_eq!(out["ok"], true, "{out}");
2719        let holds = out["holds"].clone();
2720        assert!(holds.is_null(), "holds live under result, not the envelope");
2721        let holds = out["result"]["holds"].as_array().unwrap().clone();
2722        assert_eq!(holds.len(), 3);
2723        // Order is the caller's, NOT sorted — a table row must line up with its hold.
2724        assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2725        assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2726        assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2727        // mark_scale is hoisted: one reticle at one magnification has exactly one.
2728        assert_eq!(out["result"]["mark_scale"], 1.0);
2729        assert!(holds[0]["hold"]["mark_scale"].is_null());
2730    }
2731
2732    #[test]
2733    fn a_batch_agrees_with_the_single_hold_command() {
2734        // The batch must not be a second implementation. If these ever disagree, one of
2735        // them is doing its own mark search.
2736        let single = call(json!({
2737            "api_version": 1, "command": "reticle.hold",
2738            "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2739        }));
2740        let batch = call(json!({
2741            "api_version": 1, "command": "reticle.holds",
2742            "request": {
2743                "catalog": "mil-dot", "magnification": 10.0,
2744                "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2745            },
2746        }));
2747        assert_eq!(single["ok"], true);
2748        assert_eq!(batch["ok"], true);
2749        assert_eq!(
2750            single["result"]["nearest_mark"],
2751            batch["result"]["holds"][0]["nearest_mark"]
2752        );
2753        assert_eq!(
2754            single["result"]["hold"]["off_reticle"],
2755            batch["result"]["holds"][0]["hold"]["off_reticle"]
2756        );
2757        assert_eq!(
2758            single["result"]["hold"]["nearest_mark_distance_mil"],
2759            batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2760        );
2761    }
2762
2763    #[test]
2764    fn a_malformed_hold_entry_refuses_the_whole_batch() {
2765        // A column that silently skips a row is worse than one that does not draw, so the
2766        // request fails rather than returning a list with a hole in it.
2767        //
2768        // Note WHICH failure this is. The per-row arm in run_reticle_holds is defensive
2769        // and unreachable from the wire: `hold_point_in_reticle` rejects a bad reticle and
2770        // a non-positive magnification, both of which are settled before the loop, and its
2771        // only remaining error is a non-finite hold — which JSON cannot deliver.
2772        // serde_json refuses an out-of-range literal outright ("number out of range" for
2773        // 1e999) and `json!(f64::INFINITY)` encodes as null, so a non-finite drop is
2774        // stopped by the transport. `NonFiniteHold` is reachable from the Rust API and not
2775        // from here; an earlier version of this test asserted that reason and failed,
2776        // which is how the unreachability was found.
2777        for bad in [
2778            json!(null),
2779            json!("3.0"),
2780            json!({"drop_mil": 1.0, "nonsense": 2}),
2781        ] {
2782            let out = call(json!({
2783                "api_version": 1, "command": "reticle.holds",
2784                "request": {
2785                    "catalog": "mil-dot", "magnification": 10.0,
2786                    "holds": [{"drop_mil": 1.0}, bad],
2787                },
2788            }));
2789            assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2790            assert_eq!(out["error"]["code"], "invalid_request");
2791        }
2792    }
2793
2794    #[test]
2795    fn a_bad_magnification_fails_the_batch_before_any_row() {
2796        // The reachable whole-batch failure: settled once, up front, not per row.
2797        let out = call(json!({
2798            "api_version": 1, "command": "reticle.holds",
2799            "request": {
2800                "catalog": "mil-dot", "magnification": 0.0,
2801                "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2802            },
2803        }));
2804        assert_eq!(out["ok"], false);
2805        assert_eq!(
2806            out["error"]["details"]["reason"],
2807            "non_positive_magnification"
2808        );
2809    }
2810
2811    #[test]
2812    fn an_empty_batch_is_an_empty_list_not_an_error() {
2813        // A trajectory with no samples is a legitimate thing to ask about.
2814        let out = call(json!({
2815            "api_version": 1, "command": "reticle.holds",
2816            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2817        }));
2818        assert_eq!(out["ok"], true, "{out}");
2819        assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2820    }
2821
2822    #[test]
2823    fn too_many_holds_is_a_resource_limit() {
2824        let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2825            .map(|i| json!({"drop_mil": (i % 5) as f64}))
2826            .collect();
2827        let out = call(json!({
2828            "api_version": 1, "command": "reticle.holds",
2829            "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2830        }));
2831        assert_eq!(out["ok"], false);
2832        assert_eq!(out["error"]["code"], "resource_limit");
2833    }
2834
2835    #[test]
2836    fn capabilities_lists_the_catalog_command() {
2837        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2838        let commands: Vec<String> =
2839            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2840        assert!(commands.contains(&"reticle.catalog".to_string()));
2841        assert!(commands.contains(&"reticle.holds".to_string()));
2842    }
2843
2844    #[test]
2845    fn naming_no_reticle_at_all_is_refused() {
2846        let out = call(json!({
2847            "api_version": 1, "command": "reticle.hold",
2848            "request": {"drop_mil": 1.0, "magnification": 10.0},
2849        }));
2850        assert_eq!(out["ok"], false);
2851        assert_eq!(out["error"]["code"], "invalid_request");
2852    }
2853
2854    #[test]
2855    fn plane_overrides_beside_a_full_reticle_are_refused() {
2856        // A supplied description already carries its own plane; silently overriding it
2857        // would let a request contradict the document it sent.
2858        let out = call(json!({
2859            "api_version": 1, "command": "reticle.describe",
2860            "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2861        }));
2862        assert_eq!(out["ok"], false);
2863        assert_eq!(out["error"]["code"], "invalid_request");
2864    }
2865
2866    #[test]
2867    fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2868        // The `true.dsf` convention: code stays command_failed, the machine-readable
2869        // reason is in details, and the offending value rides along so a caller can point
2870        // at the input without re-parsing the sentence.
2871        let out = call(json!({
2872            "api_version": 1, "command": "reticle.hold",
2873            "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2874        }));
2875        assert_eq!(out["ok"], false);
2876        assert_eq!(out["error"]["code"], "command_failed");
2877        assert_eq!(
2878            out["error"]["details"]["reason"],
2879            "non_positive_magnification"
2880        );
2881        assert_eq!(out["error"]["details"]["magnification"], 0.0);
2882        assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2883    }
2884
2885    #[test]
2886    fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2887        // Validation is not deferred to the hold: handing back an unusable description as
2888        // though it were usable is the failure this guards.
2889        let mut empty = hash_reticle();
2890        empty["marks"] = json!([]);
2891        let out = call(json!({
2892            "api_version": 1, "command": "reticle.describe",
2893            "request": {"reticle": empty},
2894        }));
2895        assert_eq!(out["ok"], false);
2896        assert_eq!(out["error"]["details"]["reason"], "no_marks");
2897    }
2898
2899    #[test]
2900    fn an_unreadable_generator_parameter_names_the_parameter() {
2901        let out = call(json!({
2902            "api_version": 1, "command": "reticle.describe",
2903            "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2904        }));
2905        assert_eq!(out["ok"], false);
2906        assert_eq!(
2907            out["error"]["details"]["reason"],
2908            "invalid_generator_parameter"
2909        );
2910        assert_eq!(out["error"]["details"]["parameter"], "spacing");
2911    }
2912
2913    #[test]
2914    fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2915        // Bero's own MBR dot-tree spec, the reticle that produced MBA-1440 — three rows
2916        // each stamped by a mirrored repeat, expanding to 78 holdable marks. Using the
2917        // real thing rather than a toy is deliberate: an earlier version of this test sent
2918        // a document with an invented key, and because it accepted either outcome it
2919        // passed while reaching nothing.
2920        let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2921            {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2922            {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2923            {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2924
2925        let out = call(json!({
2926            "api_version": 1, "command": "reticle.import",
2927            "request": {"format": "ventum", "document": document},
2928        }));
2929        assert_eq!(out["ok"], true, "{out}");
2930        assert_eq!(out["result"]["format"], "ventum");
2931        assert_eq!(out["result"]["mark_count"], 78);
2932        assert_eq!(out["result"]["reticle"]["name"], "MBR");
2933        assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2934        // Nothing was dropped, and the report says so rather than being absent.
2935        assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2936        assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2937
2938        // And the imported reticle is immediately usable by the command it exists for.
2939        let held = call(json!({
2940            "api_version": 1, "command": "reticle.hold",
2941            "request": {
2942                "reticle": out["result"]["reticle"],
2943                "drop_mil": 8.0,
2944                "wind_mil": 2.0,
2945                "magnification": 10.0,
2946            },
2947        }));
2948        assert_eq!(held["ok"], true, "{held}");
2949        assert_eq!(held["result"]["hold"]["off_reticle"], false);
2950        assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2951        assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2952        assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2953    }
2954
2955    #[test]
2956    fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2957        let out = call(json!({
2958            "api_version": 1, "command": "reticle.import",
2959            "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2960        }));
2961        assert_eq!(out["ok"], false);
2962        assert_eq!(out["error"]["code"], "command_failed");
2963    }
2964
2965    #[test]
2966    fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2967        // Named as the document's limit rather than the envelope's, and refused before
2968        // the parse rather than during it.
2969        let out = call(json!({
2970            "api_version": 1, "command": "reticle.import",
2971            "request": {
2972                "format": "ventum",
2973                "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2974            },
2975        }));
2976        assert_eq!(out["ok"], false);
2977        assert_eq!(out["error"]["code"], "resource_limit");
2978        assert!(out["error"]["message"]
2979            .as_str()
2980            .unwrap()
2981            .contains("reticle document"));
2982    }
2983
2984    #[test]
2985    fn every_reticle_command_rejects_an_unknown_request_field() {
2986        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2987            let out = call(json!({
2988                "api_version": 1,
2989                "command": command,
2990                "request": {"nonsense": 1},
2991            }));
2992            assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2993            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2994        }
2995    }
2996
2997    #[test]
2998    fn every_reticle_command_rejects_a_missing_payload() {
2999        for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
3000            let out = call(json!({"api_version": 1, "command": command}));
3001            assert_eq!(out["ok"], false, "{command} accepted a null payload");
3002            assert_eq!(out["error"]["code"], "invalid_request", "{command}");
3003        }
3004    }
3005
3006    /// The bridge's own end-to-end proof: a ProfileData in, a real `.a7p` out, and the
3007    /// file it hands back is one `profile.import_a7p` accepts. Round-trip fidelity of the
3008    /// individual fields is the encoder's own test; what is proved here is that the two
3009    /// bridge commands are actually inverse over the wire, base64 and all.
3010    #[cfg(all(feature = "profile-export", feature = "profile-import"))]
3011    #[test]
3012    fn export_a7p_produces_a_file_import_a7p_reads_back() {
3013        let profile = json!({
3014            "name": "bridge-export",
3015            "velocity": 792.0,
3016            "bc": 0.381,
3017            "mass": 19.439673,
3018            "diameter": 8.5852,
3019            "drag_model": "G7",
3020            "twist_rate": 254.0,
3021            "sight_height": 90.0,
3022            "zero_distance": 100.0,
3023            "units": "metric",
3024            "temperature": 15.0,
3025            "pressure": 1000.0,
3026            "humidity": 50.0,
3027            "altitude": 0.0,
3028            "bullet_name": "300GR OTM",
3029            "twist_right": false,
3030            "bullet_length": 45.72,
3031            "elevation_cf": 0.97,
3032            "dsf_points": [{"mach": 0.9, "dsf": 1.04}]
3033        });
3034
3035        let out = call(json!({
3036            "api_version": 1, "command": "profile.export_a7p", "request": profile
3037        }));
3038        assert_eq!(out["ok"], true, "{out}");
3039        let result = &out["result"];
3040        assert!(result["byte_length"].as_u64().unwrap() > 32);
3041
3042        // The honesty contract: the two fields this profile set that .a7p cannot take
3043        // are named, and nothing that DID travel is named alongside them.
3044        let dropped: Vec<String> =
3045            serde_json::from_value(result["dropped_fields"].clone()).unwrap();
3046        assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
3047        assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
3048        assert!(!dropped.contains(&"velocity".to_string()), "{out}");
3049        // ...and the full list names the unsupported fields this profile left empty too,
3050        // so a caller can tell "had none" from "lost it".
3051        let not_carried = result["not_carried"].as_array().unwrap();
3052        let reticle = not_carried
3053            .iter()
3054            .find(|e| e["field"] == "reticle")
3055            .expect("reticle is listed even though this profile has none");
3056        assert_eq!(reticle["populated"], false, "{out}");
3057        assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
3058
3059        let back = call(json!({
3060            "api_version": 1,
3061            "command": "profile.import_a7p",
3062            "request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
3063        }));
3064        // `strict` refuses on an envelope mismatch, so this passing is also the proof
3065        // that the MD5 prefix the encoder wrote is correct.
3066        assert_eq!(back["ok"], true, "{back}");
3067        let reimported = &back["result"]["profile"];
3068        assert_eq!(reimported["name"], "bridge-export", "{back}");
3069        assert_eq!(reimported["drag_model"], "G7", "{back}");
3070        assert_eq!(reimported["twist_right"], false, "{back}");
3071    }
3072
3073    /// A drag model the format cannot name is refused with the structured `reason` the
3074    /// `true.*` family established, not written out as something else.
3075    #[cfg(feature = "profile-export")]
3076    #[test]
3077    fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
3078        let out = call(json!({
3079            "api_version": 1,
3080            "command": "profile.export_a7p",
3081            // Complete in every other respect, so the refusal can only be about the
3082            // drag model: the format also REQUIRES a bullet length, a zero distance
3083            // and a sight height, and those refusals are checked in the encoder's
3084            // own tests.
3085            "request": {
3086                "name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
3087                "diameter": 8.5852, "drag_model": "G5", "units": "metric",
3088                "bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
3089                "temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
3090            }
3091        }));
3092        assert_eq!(out["error"]["code"], "command_failed", "{out}");
3093        assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
3094        assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
3095    }
3096
3097    #[cfg(feature = "profile-export")]
3098    #[test]
3099    fn export_a7p_without_a_payload_is_an_invalid_request() {
3100        let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
3101        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
3102        assert!(
3103            out["error"]["message"]
3104                .as_str()
3105                .unwrap()
3106                .contains("ProfileData"),
3107            "{out}"
3108        );
3109    }
3110}