1#[cfg(feature = "ffi")]
77pub mod ffi;
78
79use serde::{Deserialize, Serialize};
80use serde_json::{json, Value};
81use std::panic::{catch_unwind, AssertUnwindSafe};
82
83pub const BRIDGE_API_VERSION: u32 = 1;
85
86pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
88
89const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
90
91fn 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 #[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 #[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#[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
186fn 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
196pub 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
317fn 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
358fn 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
402fn 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#[cfg(feature = "pdf")]
474pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
475
476#[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#[cfg(feature = "pdf")]
505const STORED_CARD_KEY: &str = "stored_card";
506
507#[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 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#[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 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
652fn 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
658fn 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
682fn 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
711fn 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#[cfg(not(target_arch = "wasm32"))]
733#[derive(Debug, Deserialize)]
734#[serde(deny_unknown_fields)]
735struct Bc5dInfoRequest {
736 path: String,
737}
738
739#[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 "valid": true,
800 "crc_ok": true,
801 "format_version": table.version(),
802 "caliber": table.caliber(),
803 "caliber_key": table.caliber_key(),
805 "api_version": table.api_version(),
806 "generated_timestamp": table.timestamp(),
807 "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#[cfg(feature = "profile-import")]
826pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
827
828#[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#[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 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#[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 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 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 #[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 #[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 #[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 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 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 #[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 assert_eq!(out["error"]["details"]["status"], "error");
1288 }
1289}