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 let mut response = json!({
607 "pdf_base64": encode_base64(&card.pdf_bytes),
608 "byte_length": byte_length,
609 "page_count": card.page_count,
610 "row_count": card.row_count,
611 "kind": crate::card_service::PDF_CARD_KIND,
612 "source": card.source.as_str(),
613 "unprintable_title_chars": card.unprintable_title_chars,
614 });
615 if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
621 object.insert(
622 "truncation".to_string(),
623 json!({
624 "requested_end": truncation.requested_end,
625 "last_row": truncation.last_row,
626 "reach": truncation.reach,
627 }),
628 );
629 }
630 success("card.pdf", response)
631}
632
633#[cfg(feature = "pdf")]
641fn encode_base64(bytes: &[u8]) -> String {
642 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
643 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
644 for chunk in bytes.chunks(3) {
645 let triple = (u32::from(chunk[0]) << 16)
646 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
647 | u32::from(chunk.get(2).copied().unwrap_or(0));
648 out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
649 out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
650 out.push(if chunk.len() > 1 {
652 char::from(ALPHABET[(triple >> 6) as usize & 63])
653 } else {
654 '='
655 });
656 out.push(if chunk.len() > 2 {
657 char::from(ALPHABET[triple as usize & 63])
658 } else {
659 '='
660 });
661 }
662 out
663}
664
665fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
667 let details = serde_json::to_value(typed).ok();
668 error(BridgeErrorCode::CommandFailed, message, details)
669}
670
671fn decode_profile_document(
676 inner: &Value,
677 command: &'static str,
678) -> Result<crate::profile::ProfileData, String> {
679 if inner.is_null() {
680 return Err(error(
681 BridgeErrorCode::InvalidRequest,
682 format!("'{command}' requires a request payload (a ProfileData JSON document)"),
683 None,
684 ));
685 }
686 serde_json::from_value(inner.clone()).map_err(|err| {
687 error(
688 BridgeErrorCode::InvalidRequest,
689 format!("{command} request is not a ProfileData document: {err}"),
690 None,
691 )
692 })
693}
694
695fn run_profile_validate(inner: &Value) -> String {
702 let profile = match decode_profile_document(inner, "profile.validate") {
703 Ok(profile) => profile,
704 Err(envelope) => return envelope,
705 };
706 let warnings = profile.validation_warnings();
707 match serde_json::to_value(&profile) {
708 Ok(normalized) => success(
709 "profile.validate",
710 json!({
711 "valid": warnings.is_empty(),
712 "warnings": warnings,
713 "normalized": normalized,
714 }),
715 ),
716 Err(err) => error(
717 BridgeErrorCode::InternalError,
718 format!("failed to serialize normalized profile: {err}"),
719 None,
720 ),
721 }
722}
723
724fn run_profile_normalize(inner: &Value) -> String {
730 let profile = match decode_profile_document(inner, "profile.normalize") {
731 Ok(profile) => profile,
732 Err(envelope) => return envelope,
733 };
734 match serde_json::to_value(&profile) {
735 Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
736 Err(err) => error(
737 BridgeErrorCode::InternalError,
738 format!("failed to serialize normalized profile: {err}"),
739 None,
740 ),
741 }
742}
743
744#[cfg(not(target_arch = "wasm32"))]
746#[derive(Debug, Deserialize)]
747#[serde(deny_unknown_fields)]
748struct Bc5dInfoRequest {
749 path: String,
750}
751
752#[cfg(not(target_arch = "wasm32"))]
772fn run_bc5d_info(inner: &Value) -> String {
773 if inner.is_null() {
774 return error(
775 BridgeErrorCode::InvalidRequest,
776 "'bc5d.info' requires a request payload ({\"path\": ...})",
777 None,
778 );
779 }
780 let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
781 Ok(request) => request,
782 Err(err) => {
783 return error(
784 BridgeErrorCode::InvalidRequest,
785 format!("bc5d.info request rejected: {err}"),
786 None,
787 )
788 }
789 };
790
791 let table = match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(
792 &request.path,
793 )) {
794 Ok(table) => table,
795 Err(err) => {
796 return error(
797 BridgeErrorCode::CommandFailed,
798 format!("bc5d.info: not a usable BC5D table: {err}"),
799 None,
800 )
801 }
802 };
803
804 let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
805 let (weight_lo, weight_hi) = table.weight_range();
806 let (vel_lo, vel_hi) = table.velocity_range();
807 success(
808 "bc5d.info",
809 json!({
810 "valid": true,
813 "crc_ok": true,
814 "format_version": table.version(),
815 "caliber": table.caliber(),
816 "caliber_key": table.caliber_key(),
818 "api_version": table.api_version(),
819 "generated_timestamp": table.timestamp(),
820 "bins": {
822 "weight": weight,
823 "bc": bc,
824 "muzzle_velocity": muzzle_vel,
825 "current_velocity": current_vel,
826 "drag_types": drag_types,
827 },
828 "total_cells": table.total_cells(),
829 "weight_range_grains": [weight_lo, weight_hi],
830 "velocity_range_fps": [vel_lo, vel_hi],
831 }),
832 )
833}
834
835#[cfg(feature = "profile-import")]
839pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
840
841#[cfg(feature = "profile-import")]
847#[derive(Debug, Deserialize)]
848#[serde(deny_unknown_fields)]
849struct ProfileImportA7pRequest {
850 a7p_base64: String,
851 #[serde(default)]
852 zero_click: Option<String>,
853 #[serde(default)]
854 strict: bool,
855}
856
857#[cfg(feature = "profile-import")]
867fn run_profile_import_a7p(inner: &Value) -> String {
868 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
869
870 if inner.is_null() {
871 return error(
872 BridgeErrorCode::InvalidRequest,
873 "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
874 None,
875 );
876 }
877 let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
878 Ok(request) => request,
879 Err(err) => {
880 return error(
881 BridgeErrorCode::InvalidRequest,
882 format!("profile.import_a7p request rejected: {err}"),
883 None,
884 )
885 }
886 };
887
888 let zero_click = match request.zero_click.as_deref() {
889 Some(raw) => match crate::adjustment::parse_click_value(raw) {
890 Ok(click) => Some(click),
891 Err(err) => {
892 return error(
893 BridgeErrorCode::InvalidRequest,
894 format!("profile.import_a7p zero_click: {err}"),
895 None,
896 )
897 }
898 },
899 None => None,
900 };
901
902 let bytes = match decode_base64(&request.a7p_base64) {
903 Ok(bytes) => bytes,
904 Err(err) => {
905 return error(
906 BridgeErrorCode::InvalidRequest,
907 format!("profile.import_a7p a7p_base64: {err}"),
908 None,
909 )
910 }
911 };
912 if bytes.len() > MAX_A7P_DECODED_BYTES {
913 return error(
914 BridgeErrorCode::ResourceLimit,
915 format!(
916 "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
917 bytes.len()
918 ),
919 None,
920 );
921 }
922
923 let doc = match parse_a7p(&bytes) {
924 Ok(doc) => doc,
925 Err(err) => {
926 return error(
927 BridgeErrorCode::CommandFailed,
928 format!("not a usable .a7p file: {err}"),
929 None,
930 )
931 }
932 };
933 if request.strict {
936 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
937 return error(
938 BridgeErrorCode::CommandFailed,
939 format!(
940 "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
941 ),
942 None,
943 );
944 }
945 }
946
947 let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
948 Ok(outcome) => outcome,
949 Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
950 };
951 let unknown_fields: Vec<Value> = doc
952 .unknown_fields
953 .iter()
954 .map(|u| json!({ "context": u.context, "number": u.number }))
955 .collect();
956 match serde_json::to_value(&outcome.profile) {
957 Ok(profile) => success(
958 "profile.import_a7p",
959 json!({
960 "profile": profile,
961 "warnings": outcome.report.warnings,
962 "mapped": outcome.report.mapped,
963 "unmapped": outcome.report.unmapped,
964 "unknown_fields": unknown_fields,
965 }),
966 ),
967 Err(err) => error(
968 BridgeErrorCode::InternalError,
969 format!("failed to serialize imported profile: {err}"),
970 None,
971 ),
972 }
973}
974
975#[cfg(feature = "profile-import")]
983fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
984 fn sextet(c: u8) -> Result<u32, String> {
985 match c {
986 b'A'..=b'Z' => Ok(u32::from(c - b'A')),
987 b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
988 b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
989 b'+' => Ok(62),
990 b'/' => Ok(63),
991 _ => Err(format!("invalid base64 character {:?}", char::from(c))),
992 }
993 }
994 let bytes = input.as_bytes();
995 let data = match bytes {
996 [rest @ .., b'=', b'='] => rest,
997 [rest @ .., b'='] => rest,
998 _ => bytes,
999 };
1000 if data.contains(&b'=') {
1001 return Err("'=' is only valid as trailing padding".to_string());
1002 }
1003 if data.len() % 4 == 1 {
1004 return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1005 }
1006 let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1007 let mut acc: u32 = 0;
1008 let mut bits: u32 = 0;
1009 for &c in data {
1010 acc = (acc << 6) | sextet(c)?;
1011 bits += 6;
1012 if bits >= 8 {
1013 bits -= 8;
1014 out.push((acc >> bits) as u8);
1015 }
1016 }
1017 Ok(out)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 fn call(value: Value) -> Value {
1025 let raw = bridge_call(&value.to_string());
1026 serde_json::from_str(&raw).expect("bridge output must be valid JSON")
1027 }
1028
1029 #[test]
1030 fn capabilities_reports_commands_and_versions() {
1031 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1032 assert_eq!(out["ok"], true);
1033 assert_eq!(out["api_version"], 1);
1034 assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
1035 let commands: Vec<String> =
1036 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1037 assert!(commands.contains(&"solve".to_string()));
1038 assert!(commands.contains(&"meta.capabilities".to_string()));
1039 }
1040
1041 #[test]
1042 fn invalid_json_is_an_envelope_not_a_panic() {
1043 let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
1044 assert_eq!(out["ok"], false);
1045 assert_eq!(out["error"]["code"], "invalid_json");
1046 }
1047
1048 #[test]
1049 fn unknown_envelope_field_is_rejected() {
1050 let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
1051 assert_eq!(out["ok"], false);
1052 assert_eq!(out["error"]["code"], "invalid_json");
1053 }
1054
1055 #[test]
1056 fn unknown_command_lists_supported_ones() {
1057 let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
1060 assert_eq!(out["error"]["code"], "unknown_command");
1061 assert!(out["error"]["message"]
1062 .as_str()
1063 .unwrap()
1064 .contains("meta.capabilities"));
1065 }
1066
1067 #[test]
1068 fn wrong_api_version_is_rejected() {
1069 let out = call(json!({"api_version": 99, "command": "meta.version"}));
1070 assert_eq!(out["error"]["code"], "unsupported_api_version");
1071 }
1072
1073 #[test]
1074 fn oversize_request_is_a_resource_limit() {
1075 let big = format!(
1076 r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
1077 "x".repeat(MAX_REQUEST_BYTES)
1078 );
1079 let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
1080 assert_eq!(out["error"]["code"], "resource_limit");
1081 }
1082
1083 #[test]
1084 fn solve_without_payload_is_invalid_request() {
1085 let out = call(json!({"api_version": 1, "command": "solve"}));
1086 assert_eq!(out["error"]["code"], "invalid_request");
1087 }
1088
1089 #[test]
1090 fn profile_commands_without_payload_are_invalid_requests() {
1091 for command in ["profile.validate", "profile.normalize"] {
1092 let out = call(json!({"api_version": 1, "command": command}));
1093 assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
1094 assert!(
1095 out["error"]["message"]
1096 .as_str()
1097 .unwrap()
1098 .contains("ProfileData"),
1099 "{command}: {out}"
1100 );
1101 }
1102 }
1103
1104 #[test]
1105 fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
1106 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1107 let commands: Vec<String> =
1108 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1109 assert!(commands.contains(&"profile.validate".to_string()));
1110 assert!(commands.contains(&"profile.normalize".to_string()));
1111 assert_eq!(
1112 commands.contains(&"profile.import_a7p".to_string()),
1113 cfg!(feature = "profile-import"),
1114 "profile.import_a7p must be listed exactly when compiled in"
1115 );
1116 assert_eq!(
1117 commands.contains(&"bc5d.info".to_string()),
1118 cfg!(not(target_arch = "wasm32")),
1119 "bc5d.info must be listed exactly when the build has filesystem access"
1120 );
1121 }
1122
1123 #[cfg(not(target_arch = "wasm32"))]
1124 #[test]
1125 fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
1126 let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
1127 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1128
1129 let out = call(json!({
1130 "api_version": 1,
1131 "command": "bc5d.info",
1132 "request": {"path": "/nonexistent/bc5d_308.bin"}
1133 }));
1134 assert_eq!(out["error"]["code"], "command_failed", "{out}");
1135 assert!(
1136 out["error"]["message"]
1137 .as_str()
1138 .unwrap()
1139 .contains("not a usable BC5D table"),
1140 "{out}"
1141 );
1142 }
1143
1144 #[cfg(feature = "profile-import")]
1145 #[test]
1146 fn base64_decoder_round_trips_and_rejects_garbage() {
1147 for (text, bytes) in [
1149 ("", &b""[..]),
1150 ("Zg==", b"f"),
1151 ("Zm8=", b"fo"),
1152 ("Zm9v", b"foo"),
1153 ("Zm9vYg==", b"foob"),
1154 ("Zm9vYmE=", b"fooba"),
1155 ("Zm9vYmFy", b"foobar"),
1156 ] {
1157 assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1158 }
1159 assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1160 assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1161 assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1162 assert!(decode_base64("Zm9v!").is_err(), "non-alphabet byte is rejected");
1163 }
1164
1165 #[test]
1169 fn capabilities_gates_card_pdf_on_the_pdf_feature() {
1170 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1171 let commands: Vec<String> =
1172 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1173 assert_eq!(
1174 commands.contains(&"card.pdf".to_string()),
1175 cfg!(feature = "pdf"),
1176 "card.pdf must be listed exactly when compiled in: {out}"
1177 );
1178 let features: Vec<String> =
1179 serde_json::from_value(out["result"]["features"].clone()).unwrap();
1180 assert_eq!(
1181 features.contains(&"pdf".to_string()),
1182 cfg!(feature = "pdf"),
1183 "the command list and the feature list must agree: {out}"
1184 );
1185 }
1186
1187 #[cfg(not(feature = "pdf"))]
1188 #[test]
1189 fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
1190 let out = call(json!({
1191 "api_version": 1,
1192 "command": "card.pdf",
1193 "request": {
1194 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1195 "mass": 175.0, "diameter": 0.308,
1196 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
1197 }
1198 }));
1199 assert_eq!(out["error"]["code"], "unknown_command", "{out}");
1200 }
1201
1202 #[test]
1206 fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
1207 let out = call(json!({
1208 "api_version": 1,
1209 "command": "card.range_table",
1210 "request": {
1211 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1212 "mass": 175.0, "diameter": 0.308,
1213 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
1214 "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
1215 }
1216 }));
1217 assert_eq!(out["ok"], true, "{out}");
1218 assert_eq!(out["result"]["kind"], "range_table", "{out}");
1219 }
1220
1221 #[cfg(feature = "pdf")]
1222 #[test]
1223 fn card_pdf_without_payload_is_invalid_request() {
1224 let out = call(json!({"api_version": 1, "command": "card.pdf"}));
1225 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1226 assert!(
1227 out["error"]["message"].as_str().unwrap().contains("card v1 document"),
1228 "{out}"
1229 );
1230 }
1231
1232 #[cfg(feature = "pdf")]
1236 #[test]
1237 fn pdf_output_cap_refuses_only_over_the_limit() {
1238 assert!(pdf_over_cap_error(0, 0, 0).is_none());
1239 assert!(
1240 pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
1241 "a document exactly at the cap fits"
1242 );
1243 let envelope: Value =
1244 serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
1245 .unwrap();
1246 assert_eq!(envelope["ok"], false);
1247 assert_eq!(envelope["error"]["code"], "resource_limit");
1248 let message = envelope["error"]["message"].as_str().unwrap();
1249 assert!(message.contains("dope card"), "{envelope}");
1250 assert!(message.contains("6 rows"), "{envelope}");
1252 assert!(message.contains("1 pages"), "{envelope}");
1253 for absent in ["coarsen", "shorten"] {
1254 assert!(!message.contains(absent), "{envelope}");
1255 }
1256 }
1257
1258 #[cfg(feature = "pdf")]
1259 #[test]
1260 fn base64_encoder_matches_the_rfc_4648_vectors() {
1261 for (bytes, text) in [
1262 (&b""[..], ""),
1263 (b"f", "Zg=="),
1264 (b"fo", "Zm8="),
1265 (b"foo", "Zm9v"),
1266 (b"foob", "Zm9vYg=="),
1267 (b"fooba", "Zm9vYmE="),
1268 (b"foobar", "Zm9vYmFy"),
1269 ] {
1270 assert_eq!(encode_base64(bytes), text, "{bytes:?}");
1271 }
1272 assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
1275 assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
1276 assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
1277 }
1278
1279 #[cfg(all(feature = "pdf", feature = "profile-import"))]
1282 #[test]
1283 fn base64_encode_decode_round_trips_arbitrary_bytes() {
1284 for len in 0..=32usize {
1285 let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(37).wrapping_add(11)).collect();
1286 let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
1287 assert_eq!(decoded, bytes, "len {len}");
1288 }
1289 }
1290
1291 #[test]
1292 fn solve_with_bad_schema_carries_typed_details() {
1293 let out = call(json!({
1294 "api_version": 1,
1295 "command": "solve",
1296 "request": {"schema_version": 1, "unknown_field": true}
1297 }));
1298 assert_eq!(out["error"]["code"], "command_failed");
1299 assert_eq!(out["error"]["details"]["status"], "error");
1301 }
1302}