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#[cfg(feature = "ffi")]
37pub mod ffi;
38
39use serde::{Deserialize, Serialize};
40use serde_json::{json, Value};
41use std::panic::{catch_unwind, AssertUnwindSafe};
42
43/// Bridge envelope version. Bumped only for breaking envelope changes.
44pub const BRIDGE_API_VERSION: u32 = 1;
45
46/// Hard cap on request size, matching the solve-json transport.
47pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
48
49const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
50
51/// Commands available in this build, in dispatch order.
52/// `meta.capabilities` reports exactly this list so apps can feature-detect.
53fn command_names() -> Vec<&'static str> {
54    vec!["meta.capabilities", "meta.version", "solve"]
55}
56
57fn compiled_features() -> Vec<&'static str> {
58    [
59        ("pdf", cfg!(feature = "pdf")),
60        ("profile-import", cfg!(feature = "profile-import")),
61        ("online", cfg!(feature = "online")),
62    ]
63    .iter()
64    .filter(|(_, enabled)| *enabled)
65    .map(|(name, _)| *name)
66    .collect()
67}
68
69#[derive(Debug, Deserialize)]
70#[serde(deny_unknown_fields)]
71struct BridgeRequest {
72    api_version: u32,
73    command: String,
74    #[serde(default)]
75    request: Value,
76}
77
78/// Machine-readable bridge error codes. Distinct from any command's own error
79/// vocabulary: a `command_failed` carries the command's typed error in `details`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum BridgeErrorCode {
83    InvalidJson,
84    UnsupportedApiVersion,
85    UnknownCommand,
86    InvalidRequest,
87    ResourceLimit,
88    CommandFailed,
89    InternalError,
90}
91
92fn success(command: &str, result: Value) -> String {
93    serialize_envelope(&json!({
94        "ok": true,
95        "api_version": BRIDGE_API_VERSION,
96        "engine_version": ENGINE_VERSION,
97        "command": command,
98        "result": result,
99    }))
100}
101
102fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
103    let mut error = json!({
104        "code": code,
105        "message": message.into(),
106    });
107    if let Some(details) = details {
108        error["details"] = details;
109    }
110    serialize_envelope(&json!({
111        "ok": false,
112        "api_version": BRIDGE_API_VERSION,
113        "engine_version": ENGINE_VERSION,
114        "error": error,
115    }))
116}
117
118/// Serialization of the envelope itself must not be able to fail the bridge:
119/// fall back to a hand-written internal_error document.
120fn serialize_envelope(value: &Value) -> String {
121    serde_json::to_string(value).unwrap_or_else(|_| {
122        format!(
123            r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
124        )
125    })
126}
127
128/// Process one bridge exchange. Never panics; every failure mode is an in-band
129/// error envelope. This is the function the C ABI wraps.
130pub fn bridge_call(request_json: &str) -> String {
131    let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
132    guarded.unwrap_or_else(|_| {
133        error(
134            BridgeErrorCode::InternalError,
135            "bridge command failed unexpectedly",
136            None,
137        )
138    })
139}
140
141fn dispatch(request_json: &str) -> String {
142    if request_json.len() > MAX_REQUEST_BYTES {
143        return error(
144            BridgeErrorCode::ResourceLimit,
145            format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
146            None,
147        );
148    }
149
150    let request: BridgeRequest = match serde_json::from_str(request_json) {
151        Ok(request) => request,
152        Err(err) => {
153            return error(
154                BridgeErrorCode::InvalidJson,
155                format!("bridge request is not a valid envelope: {err}"),
156                None,
157            )
158        }
159    };
160
161    if request.api_version != BRIDGE_API_VERSION {
162        return error(
163            BridgeErrorCode::UnsupportedApiVersion,
164            format!(
165                "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
166                request.api_version
167            ),
168            None,
169        );
170    }
171
172    match request.command.as_str() {
173        "meta.capabilities" => success(
174            "meta.capabilities",
175            json!({
176                "engine_version": ENGINE_VERSION,
177                "bridge_api_version": BRIDGE_API_VERSION,
178                "commands": command_names(),
179                "features": compiled_features(),
180                "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
181            }),
182        ),
183        "meta.version" => success(
184            "meta.version",
185            json!({ "engine_version": ENGINE_VERSION }),
186        ),
187        "solve" => run_solve(&request.request),
188        other => error(
189            BridgeErrorCode::UnknownCommand,
190            format!(
191                "unknown command '{other}'; this build supports: {}",
192                command_names().join(", ")
193            ),
194            None,
195        ),
196    }
197}
198
199/// `solve` delegates verbatim to the solve-json v1 service. The inner request is
200/// re-serialized and run through [`crate::solve_json::decode_solve_request_v1`] so
201/// callers get the exact same schema validation (unknown-field rejection, explicit
202/// SI units, typed error locations) as the CLI `solve-json` transport.
203fn run_solve(inner: &Value) -> String {
204    if inner.is_null() {
205        return error(
206            BridgeErrorCode::InvalidRequest,
207            "'solve' requires a request payload (solve-json v1 document)",
208            None,
209        );
210    }
211    let inner_text = match serde_json::to_string(inner) {
212        Ok(text) => text,
213        Err(err) => {
214            return error(
215                BridgeErrorCode::InternalError,
216                format!("failed to re-serialize solve request: {err}"),
217                None,
218            )
219        }
220    };
221
222    let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
223        Ok(request) => request,
224        Err(envelope) => return command_error("solve request rejected", &envelope),
225    };
226
227    match crate::solve_v1(request) {
228        Ok(successful) => match serde_json::to_value(&successful) {
229            Ok(result) => success("solve", result),
230            Err(err) => error(
231                BridgeErrorCode::InternalError,
232                format!("failed to serialize solve result: {err}"),
233                None,
234            ),
235        },
236        Err(envelope) => command_error("solve failed", &envelope),
237    }
238}
239
240/// Wrap a command's own typed error envelope losslessly in `details`.
241fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
242    let details = serde_json::to_value(typed).ok();
243    error(BridgeErrorCode::CommandFailed, message, details)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn call(value: Value) -> Value {
251        let raw = bridge_call(&value.to_string());
252        serde_json::from_str(&raw).expect("bridge output must be valid JSON")
253    }
254
255    #[test]
256    fn capabilities_reports_commands_and_versions() {
257        let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
258        assert_eq!(out["ok"], true);
259        assert_eq!(out["api_version"], 1);
260        assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
261        let commands: Vec<String> =
262            serde_json::from_value(out["result"]["commands"].clone()).unwrap();
263        assert!(commands.contains(&"solve".to_string()));
264        assert!(commands.contains(&"meta.capabilities".to_string()));
265    }
266
267    #[test]
268    fn invalid_json_is_an_envelope_not_a_panic() {
269        let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
270        assert_eq!(out["ok"], false);
271        assert_eq!(out["error"]["code"], "invalid_json");
272    }
273
274    #[test]
275    fn unknown_envelope_field_is_rejected() {
276        let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
277        assert_eq!(out["ok"], false);
278        assert_eq!(out["error"]["code"], "invalid_json");
279    }
280
281    #[test]
282    fn unknown_command_lists_supported_ones() {
283        let out = call(json!({"api_version": 1, "command": "card.pdf"}));
284        assert_eq!(out["error"]["code"], "unknown_command");
285        assert!(out["error"]["message"]
286            .as_str()
287            .unwrap()
288            .contains("meta.capabilities"));
289    }
290
291    #[test]
292    fn wrong_api_version_is_rejected() {
293        let out = call(json!({"api_version": 99, "command": "meta.version"}));
294        assert_eq!(out["error"]["code"], "unsupported_api_version");
295    }
296
297    #[test]
298    fn oversize_request_is_a_resource_limit() {
299        let big = format!(
300            r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
301            "x".repeat(MAX_REQUEST_BYTES)
302        );
303        let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
304        assert_eq!(out["error"]["code"], "resource_limit");
305    }
306
307    #[test]
308    fn solve_without_payload_is_invalid_request() {
309        let out = call(json!({"api_version": 1, "command": "solve"}));
310        assert_eq!(out["error"]["code"], "invalid_request");
311    }
312
313    #[test]
314    fn solve_with_bad_schema_carries_typed_details() {
315        let out = call(json!({
316            "api_version": 1,
317            "command": "solve",
318            "request": {"schema_version": 1, "unknown_field": true}
319        }));
320        assert_eq!(out["error"]["code"], "command_failed");
321        // The solve-json envelope rides along losslessly.
322        assert_eq!(out["error"]["details"]["status"], "error");
323    }
324}