ballistics_engine/bridge/
mod.rs1#[cfg(feature = "ffi")]
37pub mod ffi;
38
39use serde::{Deserialize, Serialize};
40use serde_json::{json, Value};
41use std::panic::{catch_unwind, AssertUnwindSafe};
42
43pub const BRIDGE_API_VERSION: u32 = 1;
45
46pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
48
49const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
50
51fn 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#[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
118fn 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
128pub 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
199fn 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
240fn 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 assert_eq!(out["error"]["details"]["status"], "error");
323 }
324}