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    success(
607        "card.pdf",
608        json!({
609            "pdf_base64": encode_base64(&card.pdf_bytes),
610            "byte_length": byte_length,
611            "page_count": card.page_count,
612            "row_count": card.row_count,
613            "kind": crate::card_service::PDF_CARD_KIND,
614            "source": card.source.as_str(),
615            "unprintable_title_chars": card.unprintable_title_chars,
616        }),
617    )
618}
619
620/// RFC 4648 standard-alphabet base64 encoder with padding, for `card.pdf`.
621///
622/// Hand-rolled for the same reason as `decode_base64` below (plain text, not a doc link:
623/// that function is gated on `profile-import`, which a pdf-only build need not enable): no
624/// direct base64 dependency
625/// exists in `Cargo.toml`, and adding one for twenty lines of arithmetic would ride along on
626/// all thirteen release targets.
627#[cfg(feature = "pdf")]
628fn encode_base64(bytes: &[u8]) -> String {
629    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
630    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
631    for chunk in bytes.chunks(3) {
632        let triple = (u32::from(chunk[0]) << 16)
633            | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
634            | u32::from(chunk.get(2).copied().unwrap_or(0));
635        out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
636        out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
637        // A 1- or 2-byte tail pads rather than encoding the zero bits it never carried.
638        out.push(if chunk.len() > 1 {
639            char::from(ALPHABET[(triple >> 6) as usize & 63])
640        } else {
641            '='
642        });
643        out.push(if chunk.len() > 2 {
644            char::from(ALPHABET[triple as usize & 63])
645        } else {
646            '='
647        });
648    }
649    out
650}
651
652/// Wrap a command's own typed error envelope losslessly in `details`.
653fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
654    let details = serde_json::to_value(typed).ok();
655    error(BridgeErrorCode::CommandFailed, message, details)
656}
657
658/// Shared decode for the two profile document commands: the inner request IS a
659/// [`crate::profile::ProfileData`] JSON document (the exact schema of
660/// `~/.ballistics/profiles/*.json` — same field names, same defaults, unknown keys
661/// tolerated, exactly as the CLI loads it).
662fn decode_profile_document(
663    inner: &Value,
664    command: &'static str,
665) -> Result<crate::profile::ProfileData, String> {
666    if inner.is_null() {
667        return Err(error(
668            BridgeErrorCode::InvalidRequest,
669            format!("'{command}' requires a request payload (a ProfileData JSON document)"),
670            None,
671        ));
672    }
673    serde_json::from_value(inner.clone()).map_err(|err| {
674        error(
675            BridgeErrorCode::InvalidRequest,
676            format!("{command} request is not a ProfileData document: {err}"),
677            None,
678        )
679    })
680}
681
682/// `profile.validate`: parse a ProfileData document and run the cheap invariants the CLI
683/// applies when loading/saving a profile (units string, MBA-1358 tracking-CF band,
684/// MBA-1348 turret/optic assembly + validation including click-value parse) — see
685/// [`crate::profile::ProfileData::validation_warnings`]. No new physics checks. Result:
686/// `{ "valid": bool, "warnings": [..], "normalized": <the profile re-serialized by this
687/// engine> }` — `valid` is simply `warnings.is_empty()`.
688fn run_profile_validate(inner: &Value) -> String {
689    let profile = match decode_profile_document(inner, "profile.validate") {
690        Ok(profile) => profile,
691        Err(envelope) => return envelope,
692    };
693    let warnings = profile.validation_warnings();
694    match serde_json::to_value(&profile) {
695        Ok(normalized) => success(
696            "profile.validate",
697            json!({
698                "valid": warnings.is_empty(),
699                "warnings": warnings,
700                "normalized": normalized,
701            }),
702        ),
703        Err(err) => error(
704            BridgeErrorCode::InternalError,
705            format!("failed to serialize normalized profile: {err}"),
706            None,
707        ),
708    }
709}
710
711/// `profile.normalize`: parse a ProfileData document and hand it back re-serialized by
712/// THIS engine — the supported way for an app to round-trip a stored blob through a newer
713/// engine version (unknown keys are tolerated on input and dropped on output; defaults
714/// fill in; `skip_serializing_if` keys stay absent — the same round-trip a CLI
715/// load-then-save performs). Result: `{ "profile": <re-serialized ProfileData> }`.
716fn run_profile_normalize(inner: &Value) -> String {
717    let profile = match decode_profile_document(inner, "profile.normalize") {
718        Ok(profile) => profile,
719        Err(envelope) => return envelope,
720    };
721    match serde_json::to_value(&profile) {
722        Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
723        Err(err) => error(
724            BridgeErrorCode::InternalError,
725            format!("failed to serialize normalized profile: {err}"),
726            None,
727        ),
728    }
729}
730
731/// `bc5d.info` request payload: the filesystem path of a downloaded BC5D table.
732#[cfg(not(target_arch = "wasm32"))]
733#[derive(Debug, Deserialize)]
734#[serde(deny_unknown_fields)]
735struct Bc5dInfoRequest {
736    path: String,
737}
738
739/// `bc5d.info`: open, parse, and CRC-verify a BC5D correction table at a caller-supplied
740/// path — the exact same load-with-verification (`bc_table_5d::path_cache::load_verified`)
741/// the `solve`/card `bc5d_table_path` fields use, so "info says valid" and "the solve will
742/// accept it" cannot drift apart. Lets an app validate a table right after downloading it.
743///
744/// Result on success: `{ "valid": true, "crc_ok": true, ... }` with the identifying
745/// metadata the header carries (format version, caliber, generator API version,
746/// generation timestamp, per-axis bin counts, total cells, weight/velocity coverage).
747/// A missing, unreadable, corrupt, or non-BC5D file is a `command_failed` envelope with
748/// a human-readable message (`invalid_request` when the payload itself is malformed).
749///
750/// `caliber` is the raw header value (an `f32`, so a .308 table reports 0.30799998) and
751/// `caliber_key` is that same value as the 3-digit BC5D key — EXACTLY the integer the
752/// solve/card caliber guard compares (`Bc5dTable::ensure_caliber_matches`). An app can
753/// therefore pre-check a downloaded table itself with
754/// `round(bullet_diameter_inches * 1000) == caliber_key` and show its own friendly
755/// message instead of provoking the `command_failed`. This command deliberately does NOT
756/// take a caliber: it describes a file, and only the surfaces that have a shot in hand
757/// enforce the match.
758#[cfg(not(target_arch = "wasm32"))]
759fn run_bc5d_info(inner: &Value) -> String {
760    if inner.is_null() {
761        return error(
762            BridgeErrorCode::InvalidRequest,
763            "'bc5d.info' requires a request payload ({\"path\": ...})",
764            None,
765        );
766    }
767    let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
768        Ok(request) => request,
769        Err(err) => {
770            return error(
771                BridgeErrorCode::InvalidRequest,
772                format!("bc5d.info request rejected: {err}"),
773                None,
774            )
775        }
776    };
777
778    let table = match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(
779        &request.path,
780    )) {
781        Ok(table) => table,
782        Err(err) => {
783            return error(
784                BridgeErrorCode::CommandFailed,
785                format!("bc5d.info: not a usable BC5D table: {err}"),
786                None,
787            )
788        }
789    };
790
791    let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
792    let (weight_lo, weight_hi) = table.weight_range();
793    let (vel_lo, vel_hi) = table.velocity_range();
794    success(
795        "bc5d.info",
796        json!({
797            // Reaching here means the magic, format version, dimensions, AND the stored
798            // CRC32 all checked out — crc_ok is not a separate weaker probe.
799            "valid": true,
800            "crc_ok": true,
801            "format_version": table.version(),
802            "caliber": table.caliber(),
803            // The integer the caliber guard actually compares (see the doc comment).
804            "caliber_key": table.caliber_key(),
805            "api_version": table.api_version(),
806            "generated_timestamp": table.timestamp(),
807            // Axis order matches the on-disk layout: [drag_type][weight][bc][mv][cv].
808            "bins": {
809                "weight": weight,
810                "bc": bc,
811                "muzzle_velocity": muzzle_vel,
812                "current_velocity": current_vel,
813                "drag_types": drag_types,
814            },
815            "total_cells": table.total_cells(),
816            "weight_range_grains": [weight_lo, weight_hi],
817            "velocity_range_fps": [vel_lo, vel_hi],
818        }),
819    )
820}
821
822/// Hard cap on the DECODED `.a7p` payload accepted by `profile.import_a7p`. Real files
823/// are a few KiB; this exists purely as a resource bound (the request envelope's own
824/// [`MAX_REQUEST_BYTES`] already caps the base64 text).
825#[cfg(feature = "profile-import")]
826pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
827
828/// `profile.import_a7p` request payload. `zero_click` mirrors the CLI's `--zero-click`
829/// (the source device's click graduation, e.g. `"0.1mil"`), enabling the same optional
830/// zero_x/zero_y click-count conversion; omitted keeps the CLI's default behavior (the
831/// counts are reported as unmapped). `strict` mirrors `--strict`: reject the file on an
832/// MD5 envelope mismatch instead of importing with a warning.
833#[cfg(feature = "profile-import")]
834#[derive(Debug, Deserialize)]
835#[serde(deny_unknown_fields)]
836struct ProfileImportA7pRequest {
837    a7p_base64: String,
838    #[serde(default)]
839    zero_click: Option<String>,
840    #[serde(default)]
841    strict: bool,
842}
843
844/// `profile.import_a7p`: run the cleanroom `.a7p` parser + the SAME
845/// [`crate::profile_import::map_a7p_to_profile`] mapping the CLI's `profile import` uses,
846/// on a base64-supplied file. Result: `{ "profile": <ProfileData>, "warnings": [..],
847/// "mapped": [[source, raw, converted, destination], ..], "unmapped": [[field, why], ..],
848/// "unknown_fields": [{context, number}, ..] }` — the full import report, nothing
849/// silently dropped (`unmapped` includes the unknown-field entries too, exactly as the
850/// CLI prints them; `unknown_fields` additionally lists the parser-level unknowns in
851/// structured form). The profile name is derived from the file (sanitized); renaming is
852/// the caller's business — there is no name override here.
853#[cfg(feature = "profile-import")]
854fn run_profile_import_a7p(inner: &Value) -> String {
855    use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
856
857    if inner.is_null() {
858        return error(
859            BridgeErrorCode::InvalidRequest,
860            "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
861            None,
862        );
863    }
864    let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
865        Ok(request) => request,
866        Err(err) => {
867            return error(
868                BridgeErrorCode::InvalidRequest,
869                format!("profile.import_a7p request rejected: {err}"),
870                None,
871            )
872        }
873    };
874
875    let zero_click = match request.zero_click.as_deref() {
876        Some(raw) => match crate::adjustment::parse_click_value(raw) {
877            Ok(click) => Some(click),
878            Err(err) => {
879                return error(
880                    BridgeErrorCode::InvalidRequest,
881                    format!("profile.import_a7p zero_click: {err}"),
882                    None,
883                )
884            }
885        },
886        None => None,
887    };
888
889    let bytes = match decode_base64(&request.a7p_base64) {
890        Ok(bytes) => bytes,
891        Err(err) => {
892            return error(
893                BridgeErrorCode::InvalidRequest,
894                format!("profile.import_a7p a7p_base64: {err}"),
895                None,
896            )
897        }
898    };
899    if bytes.len() > MAX_A7P_DECODED_BYTES {
900        return error(
901            BridgeErrorCode::ResourceLimit,
902            format!(
903                "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
904                bytes.len()
905            ),
906            None,
907        );
908    }
909
910    let doc = match parse_a7p(&bytes) {
911        Ok(doc) => doc,
912        Err(err) => {
913            return error(
914                BridgeErrorCode::CommandFailed,
915                format!("not a usable .a7p file: {err}"),
916                None,
917            )
918        }
919    };
920    // Same refusal (and message) as the CLI's --strict; without it the mismatch becomes
921    // a warning in the report, also exactly as the CLI behaves.
922    if request.strict {
923        if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
924            return error(
925                BridgeErrorCode::CommandFailed,
926                format!(
927                    "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
928                ),
929                None,
930            );
931        }
932    }
933
934    let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
935        Ok(outcome) => outcome,
936        Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
937    };
938    let unknown_fields: Vec<Value> = doc
939        .unknown_fields
940        .iter()
941        .map(|u| json!({ "context": u.context, "number": u.number }))
942        .collect();
943    match serde_json::to_value(&outcome.profile) {
944        Ok(profile) => success(
945            "profile.import_a7p",
946            json!({
947                "profile": profile,
948                "warnings": outcome.report.warnings,
949                "mapped": outcome.report.mapped,
950                "unmapped": outcome.report.unmapped,
951                "unknown_fields": unknown_fields,
952            }),
953        ),
954        Err(err) => error(
955            BridgeErrorCode::InternalError,
956            format!("failed to serialize imported profile: {err}"),
957            None,
958        ),
959    }
960}
961
962/// Minimal strict RFC 4648 standard-alphabet base64 decoder for `profile.import_a7p`.
963///
964/// Hand-rolled rather than a new dependency, deliberately: the crate already carries its
965/// own cleanroom MD5 (`profile_import::md5`) and statistical constants for the same
966/// thirteen-platform reasons, `Cargo.toml` has no direct base64 dependency today, and the
967/// input here is a few KiB. Strict: rejects any character outside `A-Za-z0-9+/`, `=`
968/// anywhere but as final padding, and lengths of form 4n+1.
969#[cfg(feature = "profile-import")]
970fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
971    fn sextet(c: u8) -> Result<u32, String> {
972        match c {
973            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
974            b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
975            b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
976            b'+' => Ok(62),
977            b'/' => Ok(63),
978            _ => Err(format!("invalid base64 character {:?}", char::from(c))),
979        }
980    }
981    let bytes = input.as_bytes();
982    let data = match bytes {
983        [rest @ .., b'=', b'='] => rest,
984        [rest @ .., b'='] => rest,
985        _ => bytes,
986    };
987    if data.contains(&b'=') {
988        return Err("'=' is only valid as trailing padding".to_string());
989    }
990    if data.len() % 4 == 1 {
991        return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
992    }
993    let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
994    let mut acc: u32 = 0;
995    let mut bits: u32 = 0;
996    for &c in data {
997        acc = (acc << 6) | sextet(c)?;
998        bits += 6;
999        if bits >= 8 {
1000            bits -= 8;
1001            out.push((acc >> bits) as u8);
1002        }
1003    }
1004    Ok(out)
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    fn call(value: Value) -> Value {
1012        let raw = bridge_call(&value.to_string());
1013        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
1014    }
1015
1016    #[test]
1017    fn capabilities_reports_commands_and_versions() {
1018        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1019        assert_eq!(out["ok"], true);
1020        assert_eq!(out["api_version"], 1);
1021        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
1022        let commands: Vec<String> =
1023            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1024        assert!(commands.contains(&"solve".to_string()));
1025        assert!(commands.contains(&"meta.capabilities".to_string()));
1026    }
1027
1028    #[test]
1029    fn invalid_json_is_an_envelope_not_a_panic() {
1030        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
1031        assert_eq!(out["ok"], false);
1032        assert_eq!(out["error"]["code"], "invalid_json");
1033    }
1034
1035    #[test]
1036    fn unknown_envelope_field_is_rejected() {
1037        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
1038        assert_eq!(out["ok"], false);
1039        assert_eq!(out["error"]["code"], "invalid_json");
1040    }
1041
1042    #[test]
1043    fn unknown_command_lists_supported_ones() {
1044        // Was `card.pdf` until that became a real (pdf-gated) command; use a name no build
1045        // can ever dispatch so this test means the same thing in every feature set.
1046        let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
1047        assert_eq!(out["error"]["code"], "unknown_command");
1048        assert!(out["error"]["message"]
1049            .as_str()
1050            .unwrap()
1051            .contains("meta.capabilities"));
1052    }
1053
1054    #[test]
1055    fn wrong_api_version_is_rejected() {
1056        let out = call(json!({"api_version": 99, "command": "meta.version"}));
1057        assert_eq!(out["error"]["code"], "unsupported_api_version");
1058    }
1059
1060    #[test]
1061    fn oversize_request_is_a_resource_limit() {
1062        let big = format!(
1063            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
1064            "x".repeat(MAX_REQUEST_BYTES)
1065        );
1066        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
1067        assert_eq!(out["error"]["code"], "resource_limit");
1068    }
1069
1070    #[test]
1071    fn solve_without_payload_is_invalid_request() {
1072        let out = call(json!({"api_version": 1, "command": "solve"}));
1073        assert_eq!(out["error"]["code"], "invalid_request");
1074    }
1075
1076    #[test]
1077    fn profile_commands_without_payload_are_invalid_requests() {
1078        for command in ["profile.validate", "profile.normalize"] {
1079            let out = call(json!({"api_version": 1, "command": command}));
1080            assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
1081            assert!(
1082                out["error"]["message"]
1083                    .as_str()
1084                    .unwrap()
1085                    .contains("ProfileData"),
1086                "{command}: {out}"
1087            );
1088        }
1089    }
1090
1091    #[test]
1092    fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
1093        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1094        let commands: Vec<String> =
1095            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1096        assert!(commands.contains(&"profile.validate".to_string()));
1097        assert!(commands.contains(&"profile.normalize".to_string()));
1098        assert_eq!(
1099            commands.contains(&"profile.import_a7p".to_string()),
1100            cfg!(feature = "profile-import"),
1101            "profile.import_a7p must be listed exactly when compiled in"
1102        );
1103        assert_eq!(
1104            commands.contains(&"bc5d.info".to_string()),
1105            cfg!(not(target_arch = "wasm32")),
1106            "bc5d.info must be listed exactly when the build has filesystem access"
1107        );
1108    }
1109
1110    #[cfg(not(target_arch = "wasm32"))]
1111    #[test]
1112    fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
1113        let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
1114        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1115
1116        let out = call(json!({
1117            "api_version": 1,
1118            "command": "bc5d.info",
1119            "request": {"path": "/nonexistent/bc5d_308.bin"}
1120        }));
1121        assert_eq!(out["error"]["code"], "command_failed", "{out}");
1122        assert!(
1123            out["error"]["message"]
1124                .as_str()
1125                .unwrap()
1126                .contains("not a usable BC5D table"),
1127            "{out}"
1128        );
1129    }
1130
1131    #[cfg(feature = "profile-import")]
1132    #[test]
1133    fn base64_decoder_round_trips_and_rejects_garbage() {
1134        // RFC 4648 test vectors.
1135        for (text, bytes) in [
1136            ("", &b""[..]),
1137            ("Zg==", b"f"),
1138            ("Zm8=", b"fo"),
1139            ("Zm9v", b"foo"),
1140            ("Zm9vYg==", b"foob"),
1141            ("Zm9vYmE=", b"fooba"),
1142            ("Zm9vYmFy", b"foobar"),
1143        ] {
1144            assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1145        }
1146        assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1147        assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1148        assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1149        assert!(decode_base64("Zm9v!").is_err(), "non-alphabet byte is rejected");
1150    }
1151
1152    /// `card.pdf` must be listed exactly when the `pdf` feature is compiled in, and be an
1153    /// honest `unknown_command` otherwise — the same rule `profile.import_a7p` follows. The
1154    /// pdf-absent half of this only runs under `--no-default-features --features bridge`.
1155    #[test]
1156    fn capabilities_gates_card_pdf_on_the_pdf_feature() {
1157        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1158        let commands: Vec<String> =
1159            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1160        assert_eq!(
1161            commands.contains(&"card.pdf".to_string()),
1162            cfg!(feature = "pdf"),
1163            "card.pdf must be listed exactly when compiled in: {out}"
1164        );
1165        let features: Vec<String> =
1166            serde_json::from_value(out["result"]["features"].clone()).unwrap();
1167        assert_eq!(
1168            features.contains(&"pdf".to_string()),
1169            cfg!(feature = "pdf"),
1170            "the command list and the feature list must agree: {out}"
1171        );
1172    }
1173
1174    #[cfg(not(feature = "pdf"))]
1175    #[test]
1176    fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
1177        let out = call(json!({
1178            "api_version": 1,
1179            "command": "card.pdf",
1180            "request": {
1181                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1182                "mass": 175.0, "diameter": 0.308,
1183                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
1184            }
1185        }));
1186        assert_eq!(out["error"]["code"], "unknown_command", "{out}");
1187    }
1188
1189    /// The `pdf` presentation block must survive a build that cannot render it: an app
1190    /// stores one request per card and replays it against `card.range_table` too, so a
1191    /// pdf-less engine has to ACCEPT the field rather than reject it as unknown.
1192    #[test]
1193    fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
1194        let out = call(json!({
1195            "api_version": 1,
1196            "command": "card.range_table",
1197            "request": {
1198                "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1199                "mass": 175.0, "diameter": 0.308,
1200                "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
1201                "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
1202            }
1203        }));
1204        assert_eq!(out["ok"], true, "{out}");
1205        assert_eq!(out["result"]["kind"], "range_table", "{out}");
1206    }
1207
1208    #[cfg(feature = "pdf")]
1209    #[test]
1210    fn card_pdf_without_payload_is_invalid_request() {
1211        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
1212        assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1213        assert!(
1214            out["error"]["message"].as_str().unwrap().contains("card v1 document"),
1215            "{out}"
1216        );
1217    }
1218
1219    /// The output cap's boundary, both sides. Generating a genuinely over-cap dope card
1220    /// would take tens of thousands of rows, so the predicate is tested directly — see
1221    /// `pdf_over_cap_error`'s own comment.
1222    #[cfg(feature = "pdf")]
1223    #[test]
1224    fn pdf_output_cap_refuses_only_over_the_limit() {
1225        assert!(pdf_over_cap_error(0, 0, 0).is_none());
1226        assert!(
1227            pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
1228            "a document exactly at the cap fits"
1229        );
1230        let envelope: Value =
1231            serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
1232                .unwrap();
1233        assert_eq!(envelope["ok"], false);
1234        assert_eq!(envelope["error"]["code"], "resource_limit");
1235        let message = envelope["error"]["message"].as_str().unwrap();
1236        assert!(message.contains("dope card"), "{envelope}");
1237        // What is true of the document, not advice about controls a saved card lacks.
1238        assert!(message.contains("6 rows"), "{envelope}");
1239        assert!(message.contains("1 pages"), "{envelope}");
1240        for absent in ["coarsen", "shorten"] {
1241            assert!(!message.contains(absent), "{envelope}");
1242        }
1243    }
1244
1245    #[cfg(feature = "pdf")]
1246    #[test]
1247    fn base64_encoder_matches_the_rfc_4648_vectors() {
1248        for (bytes, text) in [
1249            (&b""[..], ""),
1250            (b"f", "Zg=="),
1251            (b"fo", "Zm8="),
1252            (b"foo", "Zm9v"),
1253            (b"foob", "Zm9vYg=="),
1254            (b"fooba", "Zm9vYmE="),
1255            (b"foobar", "Zm9vYmFy"),
1256        ] {
1257            assert_eq!(encode_base64(bytes), text, "{bytes:?}");
1258        }
1259        // Full-byte-range coverage: the >> 18 / >> 12 / >> 6 masking must not sign- or
1260        // width-mangle a high byte, which is most of a PDF's content.
1261        assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
1262        assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
1263        assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
1264    }
1265
1266    /// The encoder and the (profile-import) decoder must be inverses — the property that
1267    /// makes `pdf_base64` a lossless transport for a binary document.
1268    #[cfg(all(feature = "pdf", feature = "profile-import"))]
1269    #[test]
1270    fn base64_encode_decode_round_trips_arbitrary_bytes() {
1271        for len in 0..=32usize {
1272            let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(37).wrapping_add(11)).collect();
1273            let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
1274            assert_eq!(decoded, bytes, "len {len}");
1275        }
1276    }
1277
1278    #[test]
1279    fn solve_with_bad_schema_carries_typed_details() {
1280        let out = call(json!({
1281            "api_version": 1,
1282            "command": "solve",
1283            "request": {"schema_version": 1, "unknown_field": true}
1284        }));
1285        assert_eq!(out["error"]["code"], "command_failed");
1286        // The solve-json envelope rides along losslessly.
1287        assert_eq!(out["error"]["details"]["status"], "error");
1288    }
1289}