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 `true.*` truing family
37//!
38//! `true.fit`, `true.wind`, `true.tall_target`, `true.dsf`, `true.plan`, and `true.dial_plan`
39//! expose the engine's truing methods. All six are unconditional (no filesystem access, so
40//! all six are present on wasm32), but they are not otherwise uniform:
41//!
42//! - `true.fit` (joint MV+BC truing) is backed by the uncertainty solver, so its result
43//!   always carries `approximation` — a required enum that is either `Available` with
44//!   intervals for both muzzle velocity and BC, or `Unavailable` with a reason, never simply
45//!   absent. There is deliberately no command that returns a bare truing point estimate.
46//! - `true.wind` (effective crosswind from an observed miss) is the one exception to that
47//!   guarantee: `solve_wind_truing` has no uncertainty model, so its result is a bare point
48//!   value with no interval. Callers must not present it with `true.fit`'s confidence.
49//!   `true.wind` is also the one command whose wire shape is SI throughout (`range_m`,
50//!   `miss_right_m`, `sigma_m`) while every other command, including the rest of this
51//!   family, is imperial; apps convert at the boundary for `true.wind` specifically.
52//! - `true.tall_target` returns a scope's tracking correction factor from a tall-target
53//!   test.
54//! - `true.dsf` derives a single Mach-keyed drop-scale-factor point from an observed
55//!   transonic drop; it never persists into a profile's DSF table, which is the caller's
56//!   job. It established this family's structured-`error.details` convention: a
57//!   machine-readable `reason` (`invalid_input`, `supersonic`, `out_of_range`,
58//!   `degenerate_drop`, `forward_model`) alongside the message; `error.code` stays
59//!   `command_failed` for all commands, so existing callers are unaffected.
60//! - `true.plan` recommends which candidate ranges to shoot for a joint MV/BC truing
61//!   experiment (`crate::truing_plan::plan_truing_experiment_v1`, wired directly — no new
62//!   service function). Its error also carries structured `error.details`, under the same
63//!   `reason` key as every other command in this family (`invalid_request`,
64//!   `insufficient_reachable_candidates`, `no_feasible_design`) plus the `rejected_candidates`
65//!   diagnostics the typed error itself carries.
66//! - `true.dial_plan` turns a TRUE angular correction into ranked dial/hold/hybrid
67//!   execution plans for an INLINE optic (`crate::truing_service::dial_plan_v1`, wrapping
68//!   `crate::optic::plan_corrections`). Unlike the CLI's `dial-plan --profile` mode, there
69//!   is no profile-loading path here — the optic is supplied inline in the request, since a
70//!   saved-profile filesystem read must not enter this bridge. Its error also carries
71//!   structured `error.details`: a stable `reason` per `OpticError` variant.
72//!
73//! None of this needed a `BRIDGE_API_VERSION` bump: the six commands are additive within
74//! api_version 1, and `meta.capabilities` lists all six for feature detection.
75
76#[cfg(feature = "ffi")]
77pub mod ffi;
78
79use serde::{Deserialize, Serialize};
80use serde_json::{json, Value};
81use std::panic::{catch_unwind, AssertUnwindSafe};
82
83/// Bridge envelope version. Bumped only for breaking envelope changes.
84pub const BRIDGE_API_VERSION: u32 = 1;
85
86/// Hard cap on request size, matching the solve-json transport.
87pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
88
89const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
90
91/// Commands available in this build, in dispatch order.
92/// `meta.capabilities` reports exactly this list so apps can feature-detect.
93fn command_names() -> Vec<&'static str> {
94    let mut names = vec![
95        "meta.capabilities",
96        "meta.version",
97        "solve",
98        "card.come_ups",
99        "card.range_table",
100        "card.wind",
101    ];
102    // Listed ONLY when compiled in (mirroring compiled_features) so apps feature-detect
103    // the command list instead of probing for unknown_command. Each conditional push sits
104    // at its dispatch position so this list stays in dispatch order, as documented above.
105    #[cfg(feature = "pdf")]
106    names.push("card.pdf");
107    names.extend(["profile.validate", "profile.normalize"]);
108    #[cfg(feature = "profile-import")]
109    names.push("profile.import_a7p");
110    names.extend([
111        "true.fit",
112        "true.wind",
113        "true.tall_target",
114        "true.dsf",
115        "true.plan",
116        "true.dial_plan",
117    ]);
118    // Filesystem-backed (BC5D tables are loaded from caller-supplied paths), so absent on
119    // wasm32 — the same "list only what this build can run" rule as profile.import_a7p.
120    #[cfg(not(target_arch = "wasm32"))]
121    names.push("bc5d.info");
122    names
123}
124
125fn compiled_features() -> Vec<&'static str> {
126    [
127        ("pdf", cfg!(feature = "pdf")),
128        ("profile-import", cfg!(feature = "profile-import")),
129        ("online", cfg!(feature = "online")),
130    ]
131    .iter()
132    .filter(|(_, enabled)| *enabled)
133    .map(|(name, _)| *name)
134    .collect()
135}
136
137#[derive(Debug, Deserialize)]
138#[serde(deny_unknown_fields)]
139struct BridgeRequest {
140    api_version: u32,
141    command: String,
142    #[serde(default)]
143    request: Value,
144}
145
146/// Machine-readable bridge error codes. Distinct from any command's own error
147/// vocabulary: a `command_failed` carries the command's typed error in `details`.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
149#[serde(rename_all = "snake_case")]
150pub enum BridgeErrorCode {
151    InvalidJson,
152    UnsupportedApiVersion,
153    UnknownCommand,
154    InvalidRequest,
155    ResourceLimit,
156    CommandFailed,
157    InternalError,
158}
159
160fn success(command: &str, result: Value) -> String {
161    serialize_envelope(&json!({
162        "ok": true,
163        "api_version": BRIDGE_API_VERSION,
164        "engine_version": ENGINE_VERSION,
165        "command": command,
166        "result": result,
167    }))
168}
169
170fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
171    let mut error = json!({
172        "code": code,
173        "message": message.into(),
174    });
175    if let Some(details) = details {
176        error["details"] = details;
177    }
178    serialize_envelope(&json!({
179        "ok": false,
180        "api_version": BRIDGE_API_VERSION,
181        "engine_version": ENGINE_VERSION,
182        "error": error,
183    }))
184}
185
186/// Serialization of the envelope itself must not be able to fail the bridge:
187/// fall back to a hand-written internal_error document.
188fn serialize_envelope(value: &Value) -> String {
189    serde_json::to_string(value).unwrap_or_else(|_| {
190        format!(
191            r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
192        )
193    })
194}
195
196/// Process one bridge exchange. Never panics; every failure mode is an in-band
197/// error envelope. This is the function the C ABI wraps.
198pub fn bridge_call(request_json: &str) -> String {
199    let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
200    guarded.unwrap_or_else(|_| {
201        error(
202            BridgeErrorCode::InternalError,
203            "bridge command failed unexpectedly",
204            None,
205        )
206    })
207}
208
209fn dispatch(request_json: &str) -> String {
210    if request_json.len() > MAX_REQUEST_BYTES {
211        return error(
212            BridgeErrorCode::ResourceLimit,
213            format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
214            None,
215        );
216    }
217
218    let request: BridgeRequest = match serde_json::from_str(request_json) {
219        Ok(request) => request,
220        Err(err) => {
221            return error(
222                BridgeErrorCode::InvalidJson,
223                format!("bridge request is not a valid envelope: {err}"),
224                None,
225            )
226        }
227    };
228
229    if request.api_version != BRIDGE_API_VERSION {
230        return error(
231            BridgeErrorCode::UnsupportedApiVersion,
232            format!(
233                "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
234                request.api_version
235            ),
236            None,
237        );
238    }
239
240    match request.command.as_str() {
241        "meta.capabilities" => success(
242            "meta.capabilities",
243            json!({
244                "engine_version": ENGINE_VERSION,
245                "bridge_api_version": BRIDGE_API_VERSION,
246                "commands": command_names(),
247                "features": compiled_features(),
248                "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
249            }),
250        ),
251        "meta.version" => success(
252            "meta.version",
253            json!({ "engine_version": ENGINE_VERSION }),
254        ),
255        "solve" => run_solve(&request.request),
256        "card.come_ups" => {
257            run_service(&request.request, "card.come_ups", crate::card_service::come_ups_v1)
258        }
259        "card.range_table" => run_service(
260            &request.request,
261            "card.range_table",
262            crate::card_service::range_table_v1,
263        ),
264        "card.wind" => run_service(&request.request, "card.wind", crate::card_service::wind_card_v1),
265        #[cfg(feature = "pdf")]
266        "card.pdf" => run_card_pdf(&request.request),
267        "profile.validate" => run_profile_validate(&request.request),
268        "profile.normalize" => run_profile_normalize(&request.request),
269        #[cfg(feature = "profile-import")]
270        "profile.import_a7p" => run_profile_import_a7p(&request.request),
271        "true.fit" => run_service(
272            &request.request,
273            "true.fit",
274            crate::truing_uncertainty::run_uncertainty_truing_v1,
275        ),
276        "true.wind" => run_service(
277            &request.request,
278            "true.wind",
279            crate::truing_wind::solve_wind_truing,
280        ),
281        "true.tall_target" => run_service(
282            &request.request,
283            "true.tall_target",
284            crate::truing_service::tall_target_v1,
285        ),
286        "true.dsf" => run_service_detailed(
287            &request.request,
288            "true.dsf",
289            crate::truing_service::derive_dsf_point_v1,
290            crate::truing_service::DsfServiceErrorV1::failure_details,
291        ),
292        "true.plan" => run_service_detailed(
293            &request.request,
294            "true.plan",
295            crate::truing_plan::plan_truing_experiment_v1,
296            crate::truing_plan::TruingPlanErrorV1::failure_details,
297        ),
298        "true.dial_plan" => run_service_detailed(
299            &request.request,
300            "true.dial_plan",
301            crate::truing_service::dial_plan_v1,
302            crate::optic::OpticError::failure_details,
303        ),
304        #[cfg(not(target_arch = "wasm32"))]
305        "bc5d.info" => run_bc5d_info(&request.request),
306        other => error(
307            BridgeErrorCode::UnknownCommand,
308            format!(
309                "unknown command '{other}'; this build supports: {}",
310                command_names().join(", ")
311            ),
312            None,
313        ),
314    }
315}
316
317/// `solve` delegates verbatim to the solve-json v1 service. The inner request is
318/// re-serialized and run through [`crate::solve_json::decode_solve_request_v1`] so
319/// callers get the exact same schema validation (unknown-field rejection, explicit
320/// SI units, typed error locations) as the CLI `solve-json` transport.
321fn run_solve(inner: &Value) -> String {
322    if inner.is_null() {
323        return error(
324            BridgeErrorCode::InvalidRequest,
325            "'solve' requires a request payload (solve-json v1 document)",
326            None,
327        );
328    }
329    let inner_text = match serde_json::to_string(inner) {
330        Ok(text) => text,
331        Err(err) => {
332            return error(
333                BridgeErrorCode::InternalError,
334                format!("failed to re-serialize solve request: {err}"),
335                None,
336            )
337        }
338    };
339
340    let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
341        Ok(request) => request,
342        Err(envelope) => return command_error("solve request rejected", &envelope),
343    };
344
345    match crate::solve_v1(request) {
346        Ok(successful) => match serde_json::to_value(&successful) {
347            Ok(result) => success("solve", result),
348            Err(err) => error(
349                BridgeErrorCode::InternalError,
350                format!("failed to serialize solve result: {err}"),
351                None,
352            ),
353        },
354        Err(envelope) => command_error("solve failed", &envelope),
355    }
356}
357
358/// Shared adapter for every command backed by a transport-free service: null-check the
359/// payload, deserialize the request, call the service, serialize the response. The error
360/// mapping is fixed so all commands report failures identically.
361fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
362where
363    Req: serde::de::DeserializeOwned,
364    Resp: serde::Serialize,
365    E: std::fmt::Display,
366    F: FnOnce(&Req) -> Result<Resp, E>,
367{
368    if inner.is_null() {
369        return error(
370            BridgeErrorCode::InvalidRequest,
371            format!("'{command}' requires a request payload"),
372            None,
373        );
374    }
375    let request: Req = match serde_json::from_value(inner.clone()) {
376        Ok(request) => request,
377        Err(err) => {
378            return error(
379                BridgeErrorCode::InvalidRequest,
380                format!("{command} request rejected: {err}"),
381                None,
382            )
383        }
384    };
385    match service(&request) {
386        Ok(response) => match serde_json::to_value(&response) {
387            Ok(result) => success(command, result),
388            Err(err) => error(
389                BridgeErrorCode::InternalError,
390                format!("failed to serialize {command} result: {err}"),
391                None,
392            ),
393        },
394        Err(err) => error(
395            BridgeErrorCode::CommandFailed,
396            format!("{command} failed: {err}"),
397            None,
398        ),
399    }
400}
401
402/// [`run_service`] for a service whose error carries a machine-readable reason.
403///
404/// `details` lands in `error.details` so a wizard can branch on "supersonic" or
405/// "out_of_range" instead of pattern-matching prose. `code` stays `command_failed`, so
406/// existing callers are unaffected.
407///
408/// Callers: `true.dsf`, `true.plan`, and `true.dial_plan`, each of whose service error
409/// carries a machine-readable reason worth surfacing in `error.details`.
410fn run_service_detailed<Req, Resp, E, F, D>(
411    inner: &Value,
412    command: &'static str,
413    service: F,
414    details: D,
415) -> String
416where
417    Req: serde::de::DeserializeOwned,
418    Resp: serde::Serialize,
419    E: std::fmt::Display,
420    F: FnOnce(&Req) -> Result<Resp, E>,
421    D: FnOnce(&E) -> Option<Value>,
422{
423    if inner.is_null() {
424        return error(
425            BridgeErrorCode::InvalidRequest,
426            format!("'{command}' requires a request payload"),
427            None,
428        );
429    }
430    let request: Req = match serde_json::from_value(inner.clone()) {
431        Ok(request) => request,
432        Err(err) => {
433            return error(
434                BridgeErrorCode::InvalidRequest,
435                format!("{command} request rejected: {err}"),
436                None,
437            )
438        }
439    };
440    match service(&request) {
441        Ok(response) => match serde_json::to_value(&response) {
442            Ok(result) => success(command, result),
443            Err(err) => error(
444                BridgeErrorCode::InternalError,
445                format!("failed to serialize {command} result: {err}"),
446                None,
447            ),
448        },
449        Err(err) => {
450            let d = details(&err);
451            error(
452                BridgeErrorCode::CommandFailed,
453                format!("{command} failed: {err}"),
454                d,
455            )
456        }
457    }
458}
459
460/// Hard cap on the PDF `card.pdf` will hand back, measured on the RAW document (the
461/// base64 text in the response is ~4/3 of it, so this bounds a ~5.6 MiB response body).
462///
463/// Every dope card carries a ~815 KiB floor: the two Liberation Sans faces
464/// `pdf_dope_card` embeds. Rows are cheap on top of that (~0.5 KiB each — a 300-row,
465/// 4-page card is ~950 KiB).
466///
467/// This is the BACKSTOP, not the first line: the row set is refused on its own row and page
468/// count before any document exists (`card_service::MAX_PDF_ROWS` / `MAX_PDF_PAGES`), because
469/// measuring bytes means having already built and paginated them. What survives that check
470/// and still lands here is a card made huge by its LABELS — the `pdf` block's strings are
471/// drawn verbatim on every page — and for those a typed refusal the caller can act on beats
472/// pushing a multi-megabyte base64 string through an embedded FFI hop.
473#[cfg(feature = "pdf")]
474pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
475
476/// The over-cap envelope for a generated PDF, or `None` when it fits. Split out so the
477/// boundary itself is unit-testable at exactly [`MAX_PDF_BYTES`] and one byte past it.
478///
479/// The message states what is true of the document — its size, and how many rows and pages
480/// it holds. It deliberately does NOT advise "coarsen the step" or "shorten the range
481/// domain": for a saved card those are immutable (there is no editor for a snapshot's
482/// domain), so naming them told the one user who ever sees this message to do something
483/// impossible.
484#[cfg(feature = "pdf")]
485fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
486    (byte_length > MAX_PDF_BYTES).then(|| {
487        error(
488            BridgeErrorCode::ResourceLimit,
489            format!(
490                "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
491                 ({row_count} rows, {page_count} pages)"
492            ),
493            None,
494        )
495    })
496}
497
498/// The one `card.pdf`-only key on the request: the rows to print, instead of solving. Not a
499/// field on [`crate::card_service::CardRequestV1`], because it is not part of a saved card —
500/// it is the card's stored RESPONSE, attached at export time — and a stored request must stay
501/// replayable against `card.range_table` unchanged. Removed from the payload before the card
502/// request is decoded, so `deny_unknown_fields` still governs everything else (a
503/// `stored_cards` typo is an honest `invalid_request`).
504#[cfg(feature = "pdf")]
505const STORED_CARD_KEY: &str = "stored_card";
506
507/// `card.pdf`: the printable dope card, as base64. The request is the SAME
508/// [`crate::card_service::CardRequestV1`] the on-screen card commands take — an app stores
509/// one request per saved card and replays it here — with the optional presentation-only
510/// `pdf` block for the header/footer labels, font size, and the Lead column's target speed,
511/// plus one `card.pdf`-only key:
512///
513/// * `stored_card` (optional): `{ "card": <a stored card.range_table result, verbatim>,
514///   "engine_version": "0.34.1", "bc5d_table_version": "2.5.0" }`. Supply it and this
515///   command PRINTS THOSE ROWS: no zero solve, no trajectory, and `bc5d_table_path` is never
516///   opened, so a saved card reprints identically after an engine bump, after the correction
517///   table at that path is overwritten in place, and even after it is deleted. The footer's
518///   `BC:` is the stored card's own `bc_for_solve`, and its `Engine:`/`Table:` are the two
519///   provenance strings, so paper and screen can be reconciled afterwards.
520/// * Omit it (or send `null`, which means the same thing) and the rows are solved here, from
521///   the same `card_service::range_table_rows` call `card.range_table` makes — the
522///   pre-existing behaviour, unchanged.
523///
524/// This surface prints a range-table card and says so. A request carrying a wind card's
525/// `wind_speeds`/`wind_angles_deg`, or a `stored_card` of another kind, is REFUSED: an `ok`
526/// response whose defining field was silently ignored is worse than no response.
527///
528/// Result: `{ "pdf_base64": ..., "byte_length": <raw PDF bytes>, "page_count": ...,
529/// "row_count": ..., "kind": "range_table", "source": "solve" | "stored_rows",
530/// "unprintable_title_chars": "" }`.
531/// `byte_length` describes the DECODED document, not the base64 text; `source` lets a caller
532/// verify it got a reprint rather than a re-solve. `unprintable_title_chars` is normally
533/// empty and names the characters of `pdf.title` the card font could not draw when it is not
534/// — the card still printed, with a visible stand-in for each of them, but a caller that
535/// accepts any card name should warn rather than hand over an untitled card. A card too big
536/// to print is refused with
537/// `resource_limit` — on its row/page count first (`card_service::MAX_PDF_ROWS` /
538/// `MAX_PDF_PAGES`), and on [`MAX_PDF_BYTES`] as the backstop.
539///
540/// Other errors follow the sibling card commands exactly: a malformed payload is
541/// `invalid_request`, anything the service rejects (including an out-of-band
542/// `pdf.font_scale`) is `command_failed` with the service's own message.
543#[cfg(feature = "pdf")]
544fn run_card_pdf(inner: &Value) -> String {
545    use crate::card_service::CardServiceError;
546
547    if inner.is_null() {
548        return error(
549            BridgeErrorCode::InvalidRequest,
550            "'card.pdf' requires a request payload (card v1 document)",
551            None,
552        );
553    }
554    let mut payload = inner.clone();
555    let stored_value = payload
556        .as_object_mut()
557        .and_then(|object| object.remove(STORED_CARD_KEY))
558        .filter(|value| !value.is_null());
559    let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
560        Ok(request) => request,
561        Err(err) => {
562            return error(
563                BridgeErrorCode::InvalidRequest,
564                format!("card.pdf request rejected: {err}"),
565                None,
566            )
567        }
568    };
569    let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
570        Some(value) => match serde_json::from_value(value) {
571            Ok(stored) => Some(stored),
572            Err(err) => {
573                return error(
574                    BridgeErrorCode::InvalidRequest,
575                    format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
576                    None,
577                )
578            }
579        },
580        None => None,
581    };
582
583    let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
584        Ok(card) => card,
585        // A card too large to print is a resource refusal, not a command failure: same code
586        // the byte cap below reports, so a caller has one condition to handle.
587        Err(err @ CardServiceError::TooLarge(_)) => {
588            return error(
589                BridgeErrorCode::ResourceLimit,
590                format!("card.pdf refused: {err}"),
591                None,
592            )
593        }
594        Err(err) => {
595            return error(
596                BridgeErrorCode::CommandFailed,
597                format!("card.pdf failed: {err}"),
598                None,
599            )
600        }
601    };
602    let byte_length = card.pdf_bytes.len();
603    if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
604        return envelope;
605    }
606    let mut response = json!({
607        "pdf_base64": encode_base64(&card.pdf_bytes),
608        "byte_length": byte_length,
609        "page_count": card.page_count,
610        "row_count": card.row_count,
611        "kind": crate::card_service::PDF_CARD_KIND,
612        "source": card.source.as_str(),
613        "unprintable_title_chars": card.unprintable_title_chars,
614    });
615    // MBA-1477: additive, and spelled exactly as `card.range_table`'s own block, so an app
616    // that already reads a truncation off the on-screen card reads the printed one the same
617    // way. Absent on a card that reached every row it asked for, which is every card the
618    // previous shape could describe at all — a truncated document used to come back here
619    // indistinguishable from a complete one, with only a smaller `row_count` to notice.
620    if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
621        object.insert(
622            "truncation".to_string(),
623            json!({
624                "requested_end": truncation.requested_end,
625                "last_row": truncation.last_row,
626                "reach": truncation.reach,
627            }),
628        );
629    }
630    success("card.pdf", response)
631}
632
633/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf`.
634///
635/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
636/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
637/// direct base64 dependency
638/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
639/// all thirteen release targets.
640#[cfg(feature = "pdf")]
641fn encode_base64(bytes: &[u8]) -> String {
642    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
643    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
644    for chunk in bytes.chunks(3) {
645        let triple = (u32::from(chunk[0]) << 16)
646            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
647            | u32::from(chunk.get(2).copied().unwrap_or(0));
648        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
649        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
650        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
651        out.push(if chunk.len() > 1 {
652            char::from(ALPHABET[(triple >> 6) as usize & 63])
653        } else {
654            '='
655        });
656        out.push(if chunk.len() > 2 {
657            char::from(ALPHABET[triple as usize & 63])
658        } else {
659            '='
660        });
661    }
662    out
663}
664
665/// Wrap a command's own typed error envelope losslessly in `details`.
666fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
667    let details = serde_json::to_value(typed).ok();
668    error(BridgeErrorCode::CommandFailed, message, details)
669}
670
671/// Shared decode for the two profile document commands: the inner request IS a
672/// [`crate::profile::ProfileData`] JSON document (the exact schema of
673/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
674/// tolerated, exactly as the CLI loads it).
675fn decode_profile_document(
676    inner: &Value,
677    command: &'static str,
678) -> Result<crate::profile::ProfileData, String> {
679    if inner.is_null() {
680        return Err(error(
681            BridgeErrorCode::InvalidRequest,
682            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
683            None,
684        ));
685    }
686    serde_json::from_value(inner.clone()).map_err(|err| {
687        error(
688            BridgeErrorCode::InvalidRequest,
689            format!("{command} request is not a ProfileData document: {err}"),
690            None,
691        )
692    })
693}
694
695/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
696/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
697/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
698/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
699/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
700/// engine> }` — `valid` is simply `warnings.is_empty()`.
701fn run_profile_validate(inner: &Value) -> String {
702    let profile = match decode_profile_document(inner, "profile.validate") {
703        Ok(profile) => profile,
704        Err(envelope) => return envelope,
705    };
706    let warnings = profile.validation_warnings();
707    match serde_json::to_value(&profile) {
708        Ok(normalized) => success(
709            "profile.validate",
710            json!({
711                "valid": warnings.is_empty(),
712                "warnings": warnings,
713                "normalized": normalized,
714            }),
715        ),
716        Err(err) => error(
717            BridgeErrorCode::InternalError,
718            format!("failed to serialize normalized profile: {err}"),
719            None,
720        ),
721    }
722}
723
724/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
725/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
726/// engine version (unknown keys are tolerated on input and dropped on output; defaults
727/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
728/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
729fn run_profile_normalize(inner: &Value) -> String {
730    let profile = match decode_profile_document(inner, "profile.normalize") {
731        Ok(profile) => profile,
732        Err(envelope) => return envelope,
733    };
734    match serde_json::to_value(&profile) {
735        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
736        Err(err) => error(
737            BridgeErrorCode::InternalError,
738            format!("failed to serialize normalized profile: {err}"),
739            None,
740        ),
741    }
742}
743
744/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
745#[cfg(not(target_arch = "wasm32"))]
746#[derive(Debug, Deserialize)]
747#[serde(deny_unknown_fields)]
748struct Bc5dInfoRequest {
749    path: String,
750}
751
752/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
753/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
754/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
755/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
756///
757/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
758/// metadata the header carries (format version, caliber, generator API version,
759/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
760/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
761/// a human-readable message (`invalid_request` when the payload itself is malformed).
762///
763/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
764/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
765/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
766/// therefore pre-check a downloaded table itself with
767/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
768/// message instead of provoking the `command_failed`. This command deliberately does NOT
769/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
770/// enforce the match.
771#[cfg(not(target_arch = "wasm32"))]
772fn run_bc5d_info(inner: &Value) -> String {
773    if inner.is_null() {
774        return error(
775            BridgeErrorCode::InvalidRequest,
776            "'bc5d.info' requires a request payload ({\"path\": ...})",
777            None,
778        );
779    }
780    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
781        Ok(request) => request,
782        Err(err) => {
783            return error(
784                BridgeErrorCode::InvalidRequest,
785                format!("bc5d.info request rejected: {err}"),
786                None,
787            )
788        }
789    };
790
791    let table = match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(
792        &request.path,
793    )) {
794        Ok(table) => table,
795        Err(err) => {
796            return error(
797                BridgeErrorCode::CommandFailed,
798                format!("bc5d.info: not a usable BC5D table: {err}"),
799                None,
800            )
801        }
802    };
803
804    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
805    let (weight_lo, weight_hi) = table.weight_range();
806    let (vel_lo, vel_hi) = table.velocity_range();
807    success(
808        "bc5d.info",
809        json!({
810            // Reaching here means the magic, format version, dimensions, AND the stored
811            // CRC32 all checked out — crc_ok is not a separate weaker probe.
812            "valid": true,
813            "crc_ok": true,
814            "format_version": table.version(),
815            "caliber": table.caliber(),
816            // The integer the caliber guard actually compares (see the doc comment).
817            "caliber_key": table.caliber_key(),
818            "api_version": table.api_version(),
819            "generated_timestamp": table.timestamp(),
820            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
821            "bins": {
822                "weight": weight,
823                "bc": bc,
824                "muzzle_velocity": muzzle_vel,
825                "current_velocity": current_vel,
826                "drag_types": drag_types,
827            },
828            "total_cells": table.total_cells(),
829            "weight_range_grains": [weight_lo, weight_hi],
830            "velocity_range_fps": [vel_lo, vel_hi],
831        }),
832    )
833}
834
835/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
836/// are a few KiB; this exists purely as a resource bound (the request envelope's own
837/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
838#[cfg(feature = "profile-import")]
839pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
840
841/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
842/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
843/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
844/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
845/// MD5 envelope mismatch instead of importing with a warning.
846#[cfg(feature = "profile-import")]
847#[derive(Debug, Deserialize)]
848#[serde(deny_unknown_fields)]
849struct ProfileImportA7pRequest {
850    a7p_base64: String,
851    #[serde(default)]
852    zero_click: Option<String>,
853    #[serde(default)]
854    strict: bool,
855}
856
857/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
858/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
859/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
860/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
861/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
862/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
863/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
864/// structured form). The profile name is derived from the file (sanitized); renaming is
865/// the caller's business — there is no name override here.
866#[cfg(feature = "profile-import")]
867fn run_profile_import_a7p(inner: &Value) -> String {
868    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
869
870    if inner.is_null() {
871        return error(
872            BridgeErrorCode::InvalidRequest,
873            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
874            None,
875        );
876    }
877    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
878        Ok(request) => request,
879        Err(err) => {
880            return error(
881                BridgeErrorCode::InvalidRequest,
882                format!("profile.import_a7p request rejected: {err}"),
883                None,
884            )
885        }
886    };
887
888    let zero_click = match request.zero_click.as_deref() {
889        Some(raw) => match crate::adjustment::parse_click_value(raw) {
890            Ok(click) => Some(click),
891            Err(err) => {
892                return error(
893                    BridgeErrorCode::InvalidRequest,
894                    format!("profile.import_a7p zero_click: {err}"),
895                    None,
896                )
897            }
898        },
899        None => None,
900    };
901
902    let bytes = match decode_base64(&request.a7p_base64) {
903        Ok(bytes) => bytes,
904        Err(err) => {
905            return error(
906                BridgeErrorCode::InvalidRequest,
907                format!("profile.import_a7p a7p_base64: {err}"),
908                None,
909            )
910        }
911    };
912    if bytes.len() > MAX_A7P_DECODED_BYTES {
913        return error(
914            BridgeErrorCode::ResourceLimit,
915            format!(
916                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
917                bytes.len()
918            ),
919            None,
920        );
921    }
922
923    let doc = match parse_a7p(&bytes) {
924        Ok(doc) => doc,
925        Err(err) => {
926            return error(
927                BridgeErrorCode::CommandFailed,
928                format!("not a usable .a7p file: {err}"),
929                None,
930            )
931        }
932    };
933    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
934    // a warning in the report, also exactly as the CLI behaves.
935    if request.strict {
936        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
937            return error(
938                BridgeErrorCode::CommandFailed,
939                format!(
940                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
941                ),
942                None,
943            );
944        }
945    }
946
947    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
948        Ok(outcome) => outcome,
949        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
950    };
951    let unknown_fields: Vec<Value> = doc
952        .unknown_fields
953        .iter()
954        .map(|u| json!({ "context": u.context, "number": u.number }))
955        .collect();
956    match serde_json::to_value(&outcome.profile) {
957        Ok(profile) => success(
958            "profile.import_a7p",
959            json!({
960                "profile": profile,
961                "warnings": outcome.report.warnings,
962                "mapped": outcome.report.mapped,
963                "unmapped": outcome.report.unmapped,
964                "unknown_fields": unknown_fields,
965            }),
966        ),
967        Err(err) => error(
968            BridgeErrorCode::InternalError,
969            format!("failed to serialize imported profile: {err}"),
970            None,
971        ),
972    }
973}
974
975/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
976///
977/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
978/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
979/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
980/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
981/// anywhere but as final padding, and lengths of form 4n+1.
982#[cfg(feature = "profile-import")]
983fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
984    fn sextet(c: u8) -> Result<u32, String> {
985        match c {
986            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
987            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
988            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
989            b'+' => Ok(62),
990            b'/' => Ok(63),
991            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
992        }
993    }
994    let bytes = input.as_bytes();
995    let data = match bytes {
996        [rest @ .., b'=', b'='] => rest,
997        [rest @ .., b'='] => rest,
998        _ => bytes,
999    };
1000    if data.contains(&b'=') {
1001        return Err("'=' is only valid as trailing padding".to_string());
1002    }
1003    if data.len() % 4 == 1 {
1004        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1005    }
1006    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1007    let mut acc: u32 = 0;
1008    let mut bits: u32 = 0;
1009    for &c in data {
1010        acc = (acc << 6) | sextet(c)?;
1011        bits += 6;
1012        if bits >= 8 {
1013            bits -= 8;
1014            out.push((acc >> bits) as u8);
1015        }
1016    }
1017    Ok(out)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    fn call(value: Value) -> Value {
1025        let raw = bridge_call(&value.to_string());
1026        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
1027    }
1028
1029    #[test]
1030    fn capabilities_reports_commands_and_versions() {
1031        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1032        assert_eq!(out["ok"], true);
1033        assert_eq!(out["api_version"], 1);
1034        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
1035        let commands: Vec<String> =
1036            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1037        assert!(commands.contains(&"solve".to_string()));
1038        assert!(commands.contains(&"meta.capabilities".to_string()));
1039    }
1040
1041    #[test]
1042    fn invalid_json_is_an_envelope_not_a_panic() {
1043        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
1044        assert_eq!(out["ok"], false);
1045        assert_eq!(out["error"]["code"], "invalid_json");
1046    }
1047
1048    #[test]
1049    fn unknown_envelope_field_is_rejected() {
1050        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
1051        assert_eq!(out["ok"], false);
1052        assert_eq!(out["error"]["code"], "invalid_json");
1053    }
1054
1055    #[test]
1056    fn unknown_command_lists_supported_ones() {
1057        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
1058        // can ever dispatch so this test means the same thing in every feature set.
1059        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
1060        assert_eq!(out["error"]["code"], "unknown_command");
1061        assert!(out["error"]["message"]
1062            .as_str()
1063            .unwrap()
1064            .contains("meta.capabilities"));
1065    }
1066
1067    #[test]
1068    fn wrong_api_version_is_rejected() {
1069        let out = call(json!({"api_version": 99, "command": "meta.version"}));
1070        assert_eq!(out["error"]["code"], "unsupported_api_version");
1071    }
1072
1073    #[test]
1074    fn oversize_request_is_a_resource_limit() {
1075        let big = format!(
1076            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
1077            "x".repeat(MAX_REQUEST_BYTES)
1078        );
1079        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
1080        assert_eq!(out["error"]["code"], "resource_limit");
1081    }
1082
1083    #[test]
1084    fn solve_without_payload_is_invalid_request() {
1085        let out = call(json!({"api_version": 1, "command": "solve"}));
1086        assert_eq!(out["error"]["code"], "invalid_request");
1087    }
1088
1089    #[test]
1090    fn profile_commands_without_payload_are_invalid_requests() {
1091        for command in ["profile.validate", "profile.normalize"] {
1092            let out = call(json!({"api_version": 1, "command": command}));
1093            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
1094            assert!(
1095                out["error"]["message"]
1096                    .as_str()
1097                    .unwrap()
1098                    .contains("ProfileData"),
1099                "{command}: {out}"
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
1106        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1107        let commands: Vec<String> =
1108            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1109        assert!(commands.contains(&"profile.validate".to_string()));
1110        assert!(commands.contains(&"profile.normalize".to_string()));
1111        assert_eq!(
1112            commands.contains(&"profile.import_a7p".to_string()),
1113            cfg!(feature = "profile-import"),
1114            "profile.import_a7p must be listed exactly when compiled in"
1115        );
1116        assert_eq!(
1117            commands.contains(&"bc5d.info".to_string()),
1118            cfg!(not(target_arch = "wasm32")),
1119            "bc5d.info must be listed exactly when the build has filesystem access"
1120        );
1121    }
1122
1123    #[cfg(not(target_arch = "wasm32"))]
1124    #[test]
1125    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
1126        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
1127        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1128
1129        let out = call(json!({
1130            "api_version": 1,
1131            "command": "bc5d.info",
1132            "request": {"path": "/nonexistent/bc5d_308.bin"}
1133        }));
1134        assert_eq!(out["error"]["code"], "command_failed", "{out}");
1135        assert!(
1136            out["error"]["message"]
1137                .as_str()
1138                .unwrap()
1139                .contains("not a usable BC5D table"),
1140            "{out}"
1141        );
1142    }
1143
1144    #[cfg(feature = "profile-import")]
1145    #[test]
1146    fn base64_decoder_round_trips_and_rejects_garbage() {
1147        // RFC 4648 test vectors.
1148        for (text, bytes) in [
1149            ("", &b""[..]),
1150            ("Zg==", b"f"),
1151            ("Zm8=", b"fo"),
1152            ("Zm9v", b"foo"),
1153            ("Zm9vYg==", b"foob"),
1154            ("Zm9vYmE=", b"fooba"),
1155            ("Zm9vYmFy", b"foobar"),
1156        ] {
1157            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1158        }
1159        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1160        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1161        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1162        assert!(decode_base64("Zm9v!").is_err(), "non-alphabet byte is rejected");
1163    }
1164
1165    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
1166    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
1167    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
1168    #[test]
1169    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
1170        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1171        let commands: Vec<String> =
1172            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1173        assert_eq!(
1174            commands.contains(&"card.pdf".to_string()),
1175            cfg!(feature = "pdf"),
1176            "card.pdf must be listed exactly when compiled in: {out}"
1177        );
1178        let features: Vec<String> =
1179            serde_json::from_value(out["result"]["features"].clone()).unwrap();
1180        assert_eq!(
1181            features.contains(&"pdf".to_string()),
1182            cfg!(feature = "pdf"),
1183            "the command list and the feature list must agree: {out}"
1184        );
1185    }
1186
1187    #[cfg(not(feature = "pdf"))]
1188    #[test]
1189    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
1190        let out = call(json!({
1191            "api_version": 1,
1192            "command": "card.pdf",
1193            "request": {
1194                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1195                "mass": 175.0, "diameter": 0.308,
1196                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
1197            }
1198        }));
1199        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
1200    }
1201
1202    /// The `pdf` presentation block must survive a build that cannot render it: an app
1203    /// stores one request per card and replays it against `card.range_table` too, so a
1204    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
1205    #[test]
1206    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
1207        let out = call(json!({
1208            "api_version": 1,
1209            "command": "card.range_table",
1210            "request": {
1211                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1212                "mass": 175.0, "diameter": 0.308,
1213                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
1214                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
1215            }
1216        }));
1217        assert_eq!(out["ok"], true, "{out}");
1218        assert_eq!(out["result"]["kind"], "range_table", "{out}");
1219    }
1220
1221    #[cfg(feature = "pdf")]
1222    #[test]
1223    fn card_pdf_without_payload_is_invalid_request() {
1224        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
1225        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1226        assert!(
1227            out["error"]["message"].as_str().unwrap().contains("card v1 document"),
1228            "{out}"
1229        );
1230    }
1231
1232    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
1233    /// would take tens of thousands of rows, so the predicate is tested directly — see
1234    /// `pdf_over_cap_error`'s own comment.
1235    #[cfg(feature = "pdf")]
1236    #[test]
1237    fn pdf_output_cap_refuses_only_over_the_limit() {
1238        assert!(pdf_over_cap_error(0, 0, 0).is_none());
1239        assert!(
1240            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
1241            "a document exactly at the cap fits"
1242        );
1243        let envelope: Value =
1244            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
1245                .unwrap();
1246        assert_eq!(envelope["ok"], false);
1247        assert_eq!(envelope["error"]["code"], "resource_limit");
1248        let message = envelope["error"]["message"].as_str().unwrap();
1249        assert!(message.contains("dope card"), "{envelope}");
1250        // What is true of the document, not advice about controls a saved card lacks.
1251        assert!(message.contains("6 rows"), "{envelope}");
1252        assert!(message.contains("1 pages"), "{envelope}");
1253        for absent in ["coarsen", "shorten"] {
1254            assert!(!message.contains(absent), "{envelope}");
1255        }
1256    }
1257
1258    #[cfg(feature = "pdf")]
1259    #[test]
1260    fn base64_encoder_matches_the_rfc_4648_vectors() {
1261        for (bytes, text) in [
1262            (&b""[..], ""),
1263            (b"f", "Zg=="),
1264            (b"fo", "Zm8="),
1265            (b"foo", "Zm9v"),
1266            (b"foob", "Zm9vYg=="),
1267            (b"fooba", "Zm9vYmE="),
1268            (b"foobar", "Zm9vYmFy"),
1269        ] {
1270            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
1271        }
1272        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
1273        // width-mangle a high byte, which is most of a PDF's content.
1274        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
1275        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
1276        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
1277    }
1278
1279    /// The encoder and the (profile-import) decoder must be inverses — the property that
1280    /// makes `pdf_base64` a lossless transport for a binary document.
1281    #[cfg(all(feature = "pdf", feature = "profile-import"))]
1282    #[test]
1283    fn base64_encode_decode_round_trips_arbitrary_bytes() {
1284        for len in 0..=32usize {
1285            let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(37).wrapping_add(11)).collect();
1286            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
1287            assert_eq!(decoded, bytes, "len {len}");
1288        }
1289    }
1290
1291    #[test]
1292    fn solve_with_bad_schema_carries_typed_details() {
1293        let out = call(json!({
1294            "api_version": 1,
1295            "command": "solve",
1296            "request": {"schema_version": 1, "unknown_field": true}
1297        }));
1298        assert_eq!(out["error"]["code"], "command_failed");
1299        // The solve-json envelope rides along losslessly.
1300        assert_eq!(out["error"]["details"]["status"], "error");
1301    }
1302}