1#[cfg(feature = "ffi")]
109pub mod ffi;
110
111use serde::{Deserialize, Serialize};
112use serde_json::{json, Value};
113use std::panic::{catch_unwind, AssertUnwindSafe};
114
115pub const BRIDGE_API_VERSION: u32 = 1;
117
118pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
120
121const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
122
123fn command_names() -> Vec<&'static str> {
126 let mut names = vec![
127 "meta.capabilities",
128 "meta.version",
129 "solve",
130 "card.come_ups",
131 "card.range_table",
132 "card.wind",
133 ];
134 #[cfg(feature = "pdf")]
138 names.push("card.pdf");
139 names.extend(["profile.validate", "profile.normalize"]);
140 #[cfg(feature = "profile-import")]
141 names.push("profile.import_a7p");
142 names.extend(["reticle.describe", "reticle.hold", "reticle.import"]);
145 names.extend([
146 "true.fit",
147 "true.wind",
148 "true.tall_target",
149 "true.dsf",
150 "true.plan",
151 "true.dial_plan",
152 ]);
153 #[cfg(not(target_arch = "wasm32"))]
156 names.push("bc5d.info");
157 names
158}
159
160fn compiled_features() -> Vec<&'static str> {
161 [
162 ("pdf", cfg!(feature = "pdf")),
163 ("profile-import", cfg!(feature = "profile-import")),
164 ("online", cfg!(feature = "online")),
165 ]
166 .iter()
167 .filter(|(_, enabled)| *enabled)
168 .map(|(name, _)| *name)
169 .collect()
170}
171
172#[derive(Debug, Deserialize)]
173#[serde(deny_unknown_fields)]
174struct BridgeRequest {
175 api_version: u32,
176 command: String,
177 #[serde(default)]
178 request: Value,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
184#[serde(rename_all = "snake_case")]
185pub enum BridgeErrorCode {
186 InvalidJson,
187 UnsupportedApiVersion,
188 UnknownCommand,
189 InvalidRequest,
190 ResourceLimit,
191 CommandFailed,
192 InternalError,
193}
194
195fn success(command: &str, result: Value) -> String {
196 serialize_envelope(&json!({
197 "ok": true,
198 "api_version": BRIDGE_API_VERSION,
199 "engine_version": ENGINE_VERSION,
200 "command": command,
201 "result": result,
202 }))
203}
204
205fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
206 let mut error = json!({
207 "code": code,
208 "message": message.into(),
209 });
210 if let Some(details) = details {
211 error["details"] = details;
212 }
213 serialize_envelope(&json!({
214 "ok": false,
215 "api_version": BRIDGE_API_VERSION,
216 "engine_version": ENGINE_VERSION,
217 "error": error,
218 }))
219}
220
221fn serialize_envelope(value: &Value) -> String {
224 serde_json::to_string(value).unwrap_or_else(|_| {
225 format!(
226 r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
227 )
228 })
229}
230
231pub fn bridge_call(request_json: &str) -> String {
234 let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
235 guarded.unwrap_or_else(|_| {
236 error(
237 BridgeErrorCode::InternalError,
238 "bridge command failed unexpectedly",
239 None,
240 )
241 })
242}
243
244fn dispatch(request_json: &str) -> String {
245 if request_json.len() > MAX_REQUEST_BYTES {
246 return error(
247 BridgeErrorCode::ResourceLimit,
248 format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
249 None,
250 );
251 }
252
253 let request: BridgeRequest = match serde_json::from_str(request_json) {
254 Ok(request) => request,
255 Err(err) => {
256 return error(
257 BridgeErrorCode::InvalidJson,
258 format!("bridge request is not a valid envelope: {err}"),
259 None,
260 )
261 }
262 };
263
264 if request.api_version != BRIDGE_API_VERSION {
265 return error(
266 BridgeErrorCode::UnsupportedApiVersion,
267 format!(
268 "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
269 request.api_version
270 ),
271 None,
272 );
273 }
274
275 match request.command.as_str() {
276 "meta.capabilities" => success(
277 "meta.capabilities",
278 json!({
279 "engine_version": ENGINE_VERSION,
280 "bridge_api_version": BRIDGE_API_VERSION,
281 "commands": command_names(),
282 "features": compiled_features(),
283 "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
284 }),
285 ),
286 "meta.version" => success(
287 "meta.version",
288 json!({ "engine_version": ENGINE_VERSION }),
289 ),
290 "solve" => run_solve(&request.request),
291 "card.come_ups" => {
292 run_service(&request.request, "card.come_ups", crate::card_service::come_ups_v1)
293 }
294 "card.range_table" => run_service(
295 &request.request,
296 "card.range_table",
297 crate::card_service::range_table_v1,
298 ),
299 "card.wind" => run_service(&request.request, "card.wind", crate::card_service::wind_card_v1),
300 #[cfg(feature = "pdf")]
301 "card.pdf" => run_card_pdf(&request.request),
302 "profile.validate" => run_profile_validate(&request.request),
303 "profile.normalize" => run_profile_normalize(&request.request),
304 #[cfg(feature = "profile-import")]
305 "profile.import_a7p" => run_profile_import_a7p(&request.request),
306 "reticle.describe" => run_reticle_describe(&request.request),
307 "reticle.hold" => run_reticle_hold(&request.request),
308 "reticle.import" => run_reticle_import(&request.request),
309 "true.fit" => run_service(
310 &request.request,
311 "true.fit",
312 crate::truing_uncertainty::run_uncertainty_truing_v1,
313 ),
314 "true.wind" => run_service(
315 &request.request,
316 "true.wind",
317 crate::truing_wind::solve_wind_truing,
318 ),
319 "true.tall_target" => run_service(
320 &request.request,
321 "true.tall_target",
322 crate::truing_service::tall_target_v1,
323 ),
324 "true.dsf" => run_service_detailed(
325 &request.request,
326 "true.dsf",
327 crate::truing_service::derive_dsf_point_v1,
328 crate::truing_service::DsfServiceErrorV1::failure_details,
329 ),
330 "true.plan" => run_service_detailed(
331 &request.request,
332 "true.plan",
333 crate::truing_plan::plan_truing_experiment_v1,
334 crate::truing_plan::TruingPlanErrorV1::failure_details,
335 ),
336 "true.dial_plan" => run_service_detailed(
337 &request.request,
338 "true.dial_plan",
339 crate::truing_service::dial_plan_v1,
340 crate::optic::OpticError::failure_details,
341 ),
342 #[cfg(not(target_arch = "wasm32"))]
343 "bc5d.info" => run_bc5d_info(&request.request),
344 other => error(
345 BridgeErrorCode::UnknownCommand,
346 format!(
347 "unknown command '{other}'; this build supports: {}",
348 command_names().join(", ")
349 ),
350 None,
351 ),
352 }
353}
354
355fn run_solve(inner: &Value) -> String {
360 if inner.is_null() {
361 return error(
362 BridgeErrorCode::InvalidRequest,
363 "'solve' requires a request payload (solve-json v1 document)",
364 None,
365 );
366 }
367 let inner_text = match serde_json::to_string(inner) {
368 Ok(text) => text,
369 Err(err) => {
370 return error(
371 BridgeErrorCode::InternalError,
372 format!("failed to re-serialize solve request: {err}"),
373 None,
374 )
375 }
376 };
377
378 let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
379 Ok(request) => request,
380 Err(envelope) => return command_error("solve request rejected", &envelope),
381 };
382
383 match crate::solve_v1(request) {
384 Ok(successful) => match serde_json::to_value(&successful) {
385 Ok(result) => success("solve", result),
386 Err(err) => error(
387 BridgeErrorCode::InternalError,
388 format!("failed to serialize solve result: {err}"),
389 None,
390 ),
391 },
392 Err(envelope) => command_error("solve failed", &envelope),
393 }
394}
395
396fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
400where
401 Req: serde::de::DeserializeOwned,
402 Resp: serde::Serialize,
403 E: std::fmt::Display,
404 F: FnOnce(&Req) -> Result<Resp, E>,
405{
406 if inner.is_null() {
407 return error(
408 BridgeErrorCode::InvalidRequest,
409 format!("'{command}' requires a request payload"),
410 None,
411 );
412 }
413 let request: Req = match serde_json::from_value(inner.clone()) {
414 Ok(request) => request,
415 Err(err) => {
416 return error(
417 BridgeErrorCode::InvalidRequest,
418 format!("{command} request rejected: {err}"),
419 None,
420 )
421 }
422 };
423 match service(&request) {
424 Ok(response) => match serde_json::to_value(&response) {
425 Ok(result) => success(command, result),
426 Err(err) => error(
427 BridgeErrorCode::InternalError,
428 format!("failed to serialize {command} result: {err}"),
429 None,
430 ),
431 },
432 Err(err) => error(
433 BridgeErrorCode::CommandFailed,
434 format!("{command} failed: {err}"),
435 None,
436 ),
437 }
438}
439
440fn run_service_detailed<Req, Resp, E, F, D>(
449 inner: &Value,
450 command: &'static str,
451 service: F,
452 details: D,
453) -> String
454where
455 Req: serde::de::DeserializeOwned,
456 Resp: serde::Serialize,
457 E: std::fmt::Display,
458 F: FnOnce(&Req) -> Result<Resp, E>,
459 D: FnOnce(&E) -> Option<Value>,
460{
461 if inner.is_null() {
462 return error(
463 BridgeErrorCode::InvalidRequest,
464 format!("'{command}' requires a request payload"),
465 None,
466 );
467 }
468 let request: Req = match serde_json::from_value(inner.clone()) {
469 Ok(request) => request,
470 Err(err) => {
471 return error(
472 BridgeErrorCode::InvalidRequest,
473 format!("{command} request rejected: {err}"),
474 None,
475 )
476 }
477 };
478 match service(&request) {
479 Ok(response) => match serde_json::to_value(&response) {
480 Ok(result) => success(command, result),
481 Err(err) => error(
482 BridgeErrorCode::InternalError,
483 format!("failed to serialize {command} result: {err}"),
484 None,
485 ),
486 },
487 Err(err) => {
488 let d = details(&err);
489 error(
490 BridgeErrorCode::CommandFailed,
491 format!("{command} failed: {err}"),
492 d,
493 )
494 }
495 }
496}
497
498#[cfg(feature = "pdf")]
512pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
513
514#[cfg(feature = "pdf")]
523fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
524 (byte_length > MAX_PDF_BYTES).then(|| {
525 error(
526 BridgeErrorCode::ResourceLimit,
527 format!(
528 "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
529 ({row_count} rows, {page_count} pages)"
530 ),
531 None,
532 )
533 })
534}
535
536#[cfg(feature = "pdf")]
543const STORED_CARD_KEY: &str = "stored_card";
544
545#[cfg(feature = "pdf")]
582fn run_card_pdf(inner: &Value) -> String {
583 use crate::card_service::CardServiceError;
584
585 if inner.is_null() {
586 return error(
587 BridgeErrorCode::InvalidRequest,
588 "'card.pdf' requires a request payload (card v1 document)",
589 None,
590 );
591 }
592 let mut payload = inner.clone();
593 let stored_value = payload
594 .as_object_mut()
595 .and_then(|object| object.remove(STORED_CARD_KEY))
596 .filter(|value| !value.is_null());
597 let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
598 Ok(request) => request,
599 Err(err) => {
600 return error(
601 BridgeErrorCode::InvalidRequest,
602 format!("card.pdf request rejected: {err}"),
603 None,
604 )
605 }
606 };
607 let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
608 Some(value) => match serde_json::from_value(value) {
609 Ok(stored) => Some(stored),
610 Err(err) => {
611 return error(
612 BridgeErrorCode::InvalidRequest,
613 format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
614 None,
615 )
616 }
617 },
618 None => None,
619 };
620
621 let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
622 Ok(card) => card,
623 Err(err @ CardServiceError::TooLarge(_)) => {
626 return error(
627 BridgeErrorCode::ResourceLimit,
628 format!("card.pdf refused: {err}"),
629 None,
630 )
631 }
632 Err(err) => {
633 return error(
634 BridgeErrorCode::CommandFailed,
635 format!("card.pdf failed: {err}"),
636 None,
637 )
638 }
639 };
640 let byte_length = card.pdf_bytes.len();
641 if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
642 return envelope;
643 }
644 let mut response = json!({
645 "pdf_base64": encode_base64(&card.pdf_bytes),
646 "byte_length": byte_length,
647 "page_count": card.page_count,
648 "row_count": card.row_count,
649 "kind": crate::card_service::PDF_CARD_KIND,
650 "source": card.source.as_str(),
651 "unprintable_title_chars": card.unprintable_title_chars,
652 });
653 if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
659 object.insert(
660 "truncation".to_string(),
661 json!({
662 "requested_end": truncation.requested_end,
663 "last_row": truncation.last_row,
664 "reach": truncation.reach,
665 }),
666 );
667 }
668 success("card.pdf", response)
669}
670
671#[cfg(feature = "pdf")]
679fn encode_base64(bytes: &[u8]) -> String {
680 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
681 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
682 for chunk in bytes.chunks(3) {
683 let triple = (u32::from(chunk[0]) << 16)
684 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
685 | u32::from(chunk.get(2).copied().unwrap_or(0));
686 out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
687 out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
688 out.push(if chunk.len() > 1 {
690 char::from(ALPHABET[(triple >> 6) as usize & 63])
691 } else {
692 '='
693 });
694 out.push(if chunk.len() > 2 {
695 char::from(ALPHABET[triple as usize & 63])
696 } else {
697 '='
698 });
699 }
700 out
701}
702
703fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
705 let details = serde_json::to_value(typed).ok();
706 error(BridgeErrorCode::CommandFailed, message, details)
707}
708
709fn decode_profile_document(
714 inner: &Value,
715 command: &'static str,
716) -> Result<crate::profile::ProfileData, String> {
717 if inner.is_null() {
718 return Err(error(
719 BridgeErrorCode::InvalidRequest,
720 format!("'{command}' requires a request payload (a ProfileData JSON document)"),
721 None,
722 ));
723 }
724 serde_json::from_value(inner.clone()).map_err(|err| {
725 error(
726 BridgeErrorCode::InvalidRequest,
727 format!("{command} request is not a ProfileData document: {err}"),
728 None,
729 )
730 })
731}
732
733fn run_profile_validate(inner: &Value) -> String {
740 let profile = match decode_profile_document(inner, "profile.validate") {
741 Ok(profile) => profile,
742 Err(envelope) => return envelope,
743 };
744 let warnings = profile.validation_warnings();
745 match serde_json::to_value(&profile) {
746 Ok(normalized) => success(
747 "profile.validate",
748 json!({
749 "valid": warnings.is_empty(),
750 "warnings": warnings,
751 "normalized": normalized,
752 }),
753 ),
754 Err(err) => error(
755 BridgeErrorCode::InternalError,
756 format!("failed to serialize normalized profile: {err}"),
757 None,
758 ),
759 }
760}
761
762fn run_profile_normalize(inner: &Value) -> String {
768 let profile = match decode_profile_document(inner, "profile.normalize") {
769 Ok(profile) => profile,
770 Err(envelope) => return envelope,
771 };
772 match serde_json::to_value(&profile) {
773 Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
774 Err(err) => error(
775 BridgeErrorCode::InternalError,
776 format!("failed to serialize normalized profile: {err}"),
777 None,
778 ),
779 }
780}
781
782#[cfg(not(target_arch = "wasm32"))]
784#[derive(Debug, Deserialize)]
785#[serde(deny_unknown_fields)]
786struct Bc5dInfoRequest {
787 path: String,
788}
789
790#[cfg(not(target_arch = "wasm32"))]
810fn run_bc5d_info(inner: &Value) -> String {
811 if inner.is_null() {
812 return error(
813 BridgeErrorCode::InvalidRequest,
814 "'bc5d.info' requires a request payload ({\"path\": ...})",
815 None,
816 );
817 }
818 let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
819 Ok(request) => request,
820 Err(err) => {
821 return error(
822 BridgeErrorCode::InvalidRequest,
823 format!("bc5d.info request rejected: {err}"),
824 None,
825 )
826 }
827 };
828
829 let table = match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(
830 &request.path,
831 )) {
832 Ok(table) => table,
833 Err(err) => {
834 return error(
835 BridgeErrorCode::CommandFailed,
836 format!("bc5d.info: not a usable BC5D table: {err}"),
837 None,
838 )
839 }
840 };
841
842 let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
843 let (weight_lo, weight_hi) = table.weight_range();
844 let (vel_lo, vel_hi) = table.velocity_range();
845 success(
846 "bc5d.info",
847 json!({
848 "valid": true,
851 "crc_ok": true,
852 "format_version": table.version(),
853 "caliber": table.caliber(),
854 "caliber_key": table.caliber_key(),
856 "api_version": table.api_version(),
857 "generated_timestamp": table.timestamp(),
858 "bins": {
860 "weight": weight,
861 "bc": bc,
862 "muzzle_velocity": muzzle_vel,
863 "current_velocity": current_vel,
864 "drag_types": drag_types,
865 },
866 "total_cells": table.total_cells(),
867 "weight_range_grains": [weight_lo, weight_hi],
868 "velocity_range_fps": [vel_lo, vel_hi],
869 }),
870 )
871}
872
873#[cfg(feature = "profile-import")]
877pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
878
879#[cfg(feature = "profile-import")]
885#[derive(Debug, Deserialize)]
886#[serde(deny_unknown_fields)]
887struct ProfileImportA7pRequest {
888 a7p_base64: String,
889 #[serde(default)]
890 zero_click: Option<String>,
891 #[serde(default)]
892 strict: bool,
893}
894
895#[cfg(feature = "profile-import")]
905fn run_profile_import_a7p(inner: &Value) -> String {
906 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
907
908 if inner.is_null() {
909 return error(
910 BridgeErrorCode::InvalidRequest,
911 "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
912 None,
913 );
914 }
915 let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
916 Ok(request) => request,
917 Err(err) => {
918 return error(
919 BridgeErrorCode::InvalidRequest,
920 format!("profile.import_a7p request rejected: {err}"),
921 None,
922 )
923 }
924 };
925
926 let zero_click = match request.zero_click.as_deref() {
927 Some(raw) => match crate::adjustment::parse_click_value(raw) {
928 Ok(click) => Some(click),
929 Err(err) => {
930 return error(
931 BridgeErrorCode::InvalidRequest,
932 format!("profile.import_a7p zero_click: {err}"),
933 None,
934 )
935 }
936 },
937 None => None,
938 };
939
940 let bytes = match decode_base64(&request.a7p_base64) {
941 Ok(bytes) => bytes,
942 Err(err) => {
943 return error(
944 BridgeErrorCode::InvalidRequest,
945 format!("profile.import_a7p a7p_base64: {err}"),
946 None,
947 )
948 }
949 };
950 if bytes.len() > MAX_A7P_DECODED_BYTES {
951 return error(
952 BridgeErrorCode::ResourceLimit,
953 format!(
954 "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
955 bytes.len()
956 ),
957 None,
958 );
959 }
960
961 let doc = match parse_a7p(&bytes) {
962 Ok(doc) => doc,
963 Err(err) => {
964 return error(
965 BridgeErrorCode::CommandFailed,
966 format!("not a usable .a7p file: {err}"),
967 None,
968 )
969 }
970 };
971 if request.strict {
974 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
975 return error(
976 BridgeErrorCode::CommandFailed,
977 format!(
978 "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
979 ),
980 None,
981 );
982 }
983 }
984
985 let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
986 Ok(outcome) => outcome,
987 Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
988 };
989 let unknown_fields: Vec<Value> = doc
990 .unknown_fields
991 .iter()
992 .map(|u| json!({ "context": u.context, "number": u.number }))
993 .collect();
994 match serde_json::to_value(&outcome.profile) {
995 Ok(profile) => success(
996 "profile.import_a7p",
997 json!({
998 "profile": profile,
999 "warnings": outcome.report.warnings,
1000 "mapped": outcome.report.mapped,
1001 "unmapped": outcome.report.unmapped,
1002 "unknown_fields": unknown_fields,
1003 }),
1004 ),
1005 Err(err) => error(
1006 BridgeErrorCode::InternalError,
1007 format!("failed to serialize imported profile: {err}"),
1008 None,
1009 ),
1010 }
1011}
1012
1013#[cfg(feature = "profile-import")]
1021fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1022 fn sextet(c: u8) -> Result<u32, String> {
1023 match c {
1024 b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1025 b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1026 b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1027 b'+' => Ok(62),
1028 b'/' => Ok(63),
1029 _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1030 }
1031 }
1032 let bytes = input.as_bytes();
1033 let data = match bytes {
1034 [rest @ .., b'=', b'='] => rest,
1035 [rest @ .., b'='] => rest,
1036 _ => bytes,
1037 };
1038 if data.contains(&b'=') {
1039 return Err("'=' is only valid as trailing padding".to_string());
1040 }
1041 if data.len() % 4 == 1 {
1042 return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1043 }
1044 let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1045 let mut acc: u32 = 0;
1046 let mut bits: u32 = 0;
1047 for &c in data {
1048 acc = (acc << 6) | sextet(c)?;
1049 bits += 6;
1050 if bits >= 8 {
1051 bits -= 8;
1052 out.push((acc >> bits) as u8);
1053 }
1054 }
1055 Ok(out)
1056}
1057
1058#[derive(Debug, Deserialize)]
1068#[serde(tag = "kind", rename_all = "snake_case")]
1069enum ReticleGenerator {
1070 MilGrid { spacing_mil: f64, extent_mil: f64 },
1072 Tree {
1074 rows: usize,
1075 row_spacing_mil: f64,
1076 spread_step_mil: f64,
1077 },
1078 Bdc { drops: Vec<(f64, f64)> },
1083}
1084
1085#[derive(Debug, Default, Deserialize)]
1093#[serde(deny_unknown_fields)]
1094struct ReticlePlaneOverride {
1095 #[serde(default)]
1096 focal_plane: Option<crate::reticle::FocalPlane>,
1097 #[serde(default)]
1098 reference_magnification: Option<f64>,
1099}
1100
1101fn resolve_reticle(
1105 command: &'static str,
1106 reticle: Option<crate::reticle::ReticleDescription>,
1107 generator: Option<ReticleGenerator>,
1108 plane: &ReticlePlaneOverride,
1109) -> Result<crate::reticle::ReticleDescription, String> {
1110 use crate::reticle::ReticleDescription;
1111
1112 let mut described = match (reticle, generator) {
1113 (Some(_), Some(_)) => {
1114 return Err(error(
1115 BridgeErrorCode::InvalidRequest,
1116 format!(
1117 "{command} takes either 'reticle' or 'generator', not both — \
1118 supplying both is a caller bug rather than a preference"
1119 ),
1120 None,
1121 ))
1122 }
1123 (None, None) => {
1124 return Err(error(
1125 BridgeErrorCode::InvalidRequest,
1126 format!("{command} requires either 'reticle' (a full description) or 'generator'"),
1127 None,
1128 ))
1129 }
1130 (Some(reticle), None) => {
1131 if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1132 return Err(error(
1133 BridgeErrorCode::InvalidRequest,
1134 format!(
1135 "{command}: 'focal_plane' and 'reference_magnification' override a \
1136 GENERATED reticle; a supplied 'reticle' already carries its own"
1137 ),
1138 None,
1139 ));
1140 }
1141 reticle
1142 }
1143 (None, Some(generator)) => {
1144 let built = match generator {
1145 ReticleGenerator::MilGrid {
1146 spacing_mil,
1147 extent_mil,
1148 } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1149 ReticleGenerator::Tree {
1150 rows,
1151 row_spacing_mil,
1152 spread_step_mil,
1153 } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1154 ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1155 };
1156 match built {
1157 Ok(built) => built,
1158 Err(err) => return Err(reticle_error_envelope(command, &err)),
1159 }
1160 }
1161 };
1162
1163 if let Some(focal_plane) = plane.focal_plane {
1164 described.focal_plane = focal_plane;
1165 }
1166 if let Some(reference_magnification) = plane.reference_magnification {
1167 described.reference_magnification = reference_magnification;
1168 }
1169 Ok(described)
1170}
1171
1172fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1180 use crate::reticle::ReticleError;
1181
1182 let mut details = json!({ "reason": match err {
1183 ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1184 ReticleError::NonPositiveReferenceMagnification { .. } => {
1185 "non_positive_reference_magnification"
1186 }
1187 ReticleError::NoMarks => "no_marks",
1188 ReticleError::TooManyMarks { .. } => "too_many_marks",
1189 ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1190 ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1191 ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1192 ReticleError::InvalidSpec(_) => "invalid_spec",
1193 }});
1194
1195 match err {
1198 ReticleError::NonPositiveMagnification { magnification } => {
1199 details["magnification"] = json!(magnification);
1200 }
1201 ReticleError::NonPositiveReferenceMagnification {
1202 reference_magnification,
1203 } => {
1204 details["reference_magnification"] = json!(reference_magnification);
1205 }
1206 ReticleError::TooManyMarks { count, max } => {
1207 details["count"] = json!(count);
1208 details["max"] = json!(max);
1209 }
1210 ReticleError::NonFiniteMark { index } => {
1211 details["index"] = json!(index);
1212 }
1213 ReticleError::InvalidGeneratorParameter {
1214 parameter,
1215 value,
1216 rule,
1217 } => {
1218 details["parameter"] = json!(parameter);
1219 details["value"] = json!(value);
1220 details["rule"] = json!(rule);
1221 }
1222 ReticleError::NoMarks
1223 | ReticleError::NonFiniteHold { .. }
1224 | ReticleError::InvalidSpec(_) => {}
1225 }
1226 details["command"] = json!(command);
1227
1228 error(
1229 BridgeErrorCode::CommandFailed,
1230 err.to_string(),
1231 Some(details),
1232 )
1233}
1234
1235fn nearest_mark_value(
1243 reticle: &crate::reticle::ReticleDescription,
1244 hold: &crate::reticle::ReticleHold,
1245) -> Value {
1246 match hold
1247 .nearest_mark
1248 .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1249 {
1250 Some((index, mark)) => json!({
1251 "index": index,
1252 "kind": mark.kind.as_str(),
1253 "label": mark.label,
1254 "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1255 "true_angular": {
1256 "down_mil": mark.down_mil * hold.mark_scale,
1257 "right_mil": mark.right_mil * hold.mark_scale,
1258 },
1259 }),
1260 None => Value::Null,
1261 }
1262}
1263
1264#[derive(Debug, Deserialize)]
1265#[serde(deny_unknown_fields)]
1266struct ReticleDescribeRequest {
1267 #[serde(default)]
1268 reticle: Option<crate::reticle::ReticleDescription>,
1269 #[serde(default)]
1270 generator: Option<ReticleGenerator>,
1271 #[serde(default)]
1272 focal_plane: Option<crate::reticle::FocalPlane>,
1273 #[serde(default)]
1274 reference_magnification: Option<f64>,
1275 #[serde(default)]
1278 magnification: Option<f64>,
1279}
1280
1281fn run_reticle_describe(inner: &Value) -> String {
1291 if inner.is_null() {
1292 return error(
1293 BridgeErrorCode::InvalidRequest,
1294 "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1295 None,
1296 );
1297 }
1298 let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1299 Ok(request) => request,
1300 Err(err) => {
1301 return error(
1302 BridgeErrorCode::InvalidRequest,
1303 format!("reticle.describe request rejected: {err}"),
1304 None,
1305 )
1306 }
1307 };
1308
1309 let plane = ReticlePlaneOverride {
1310 focal_plane: request.focal_plane,
1311 reference_magnification: request.reference_magnification,
1312 };
1313 let reticle = match resolve_reticle(
1314 "reticle.describe",
1315 request.reticle,
1316 request.generator,
1317 &plane,
1318 ) {
1319 Ok(reticle) => reticle,
1320 Err(envelope) => return envelope,
1321 };
1322
1323 if let Err(err) = reticle.validate() {
1326 return reticle_error_envelope("reticle.describe", &err);
1327 }
1328
1329 let scaled = match request.magnification {
1330 Some(magnification) => match reticle.scaled_marks(magnification) {
1331 Ok(marks) => json!({
1332 "magnification": magnification,
1333 "mark_scale": reticle.mark_scale(magnification),
1334 "marks": marks
1335 .iter()
1336 .map(|mark| json!({
1337 "down_mil": mark.down_mil,
1338 "right_mil": mark.right_mil,
1339 }))
1340 .collect::<Vec<_>>(),
1341 }),
1342 Err(err) => return reticle_error_envelope("reticle.describe", &err),
1343 },
1344 None => Value::Null,
1345 };
1346
1347 match serde_json::to_value(&reticle) {
1348 Ok(described) => success(
1349 "reticle.describe",
1350 json!({
1351 "reticle": described,
1352 "focal_plane": reticle.focal_plane.label(),
1353 "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1354 "mark_count": reticle.marks.len(),
1355 "scaled": scaled,
1356 }),
1357 ),
1358 Err(err) => error(
1359 BridgeErrorCode::InternalError,
1360 format!("failed to serialize reticle description: {err}"),
1361 None,
1362 ),
1363 }
1364}
1365
1366#[derive(Debug, Deserialize)]
1367#[serde(deny_unknown_fields)]
1368struct ReticleHoldRequest {
1369 #[serde(default)]
1370 reticle: Option<crate::reticle::ReticleDescription>,
1371 #[serde(default)]
1372 generator: Option<ReticleGenerator>,
1373 #[serde(default)]
1374 focal_plane: Option<crate::reticle::FocalPlane>,
1375 #[serde(default)]
1376 reference_magnification: Option<f64>,
1377 drop_mil: f64,
1380 #[serde(default)]
1382 wind_mil: f64,
1383 magnification: f64,
1387}
1388
1389fn run_reticle_hold(inner: &Value) -> String {
1405 if inner.is_null() {
1406 return error(
1407 BridgeErrorCode::InvalidRequest,
1408 "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1409 None,
1410 );
1411 }
1412 let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1413 Ok(request) => request,
1414 Err(err) => {
1415 return error(
1416 BridgeErrorCode::InvalidRequest,
1417 format!("reticle.hold request rejected: {err}"),
1418 None,
1419 )
1420 }
1421 };
1422
1423 let plane = ReticlePlaneOverride {
1424 focal_plane: request.focal_plane,
1425 reference_magnification: request.reference_magnification,
1426 };
1427 let reticle = match resolve_reticle("reticle.hold", request.reticle, request.generator, &plane)
1428 {
1429 Ok(reticle) => reticle,
1430 Err(envelope) => return envelope,
1431 };
1432
1433 let hold = match crate::reticle::hold_point_in_reticle(
1434 request.drop_mil,
1435 request.wind_mil,
1436 request.magnification,
1437 &reticle,
1438 ) {
1439 Ok(hold) => hold,
1440 Err(err) => return reticle_error_envelope("reticle.hold", &err),
1441 };
1442
1443 let nearest = nearest_mark_value(&reticle, &hold);
1444 match serde_json::to_value(&hold) {
1445 Ok(hold_value) => success(
1446 "reticle.hold",
1447 json!({
1448 "hold": hold_value,
1449 "nearest_mark": nearest,
1450 }),
1451 ),
1452 Err(err) => error(
1453 BridgeErrorCode::InternalError,
1454 format!("failed to serialize reticle hold: {err}"),
1455 None,
1456 ),
1457 }
1458}
1459
1460pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1468
1469#[derive(Debug, Deserialize)]
1470#[serde(rename_all = "snake_case")]
1471enum ReticleDocumentFormat {
1472 Ventum,
1474 ReticleXml,
1476}
1477
1478#[derive(Debug, Deserialize)]
1479#[serde(deny_unknown_fields)]
1480struct ReticleImportRequest {
1481 format: ReticleDocumentFormat,
1482 document: String,
1484}
1485
1486fn run_reticle_import(inner: &Value) -> String {
1495 if inner.is_null() {
1496 return error(
1497 BridgeErrorCode::InvalidRequest,
1498 "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1499 None,
1500 );
1501 }
1502 let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1503 Ok(request) => request,
1504 Err(err) => {
1505 return error(
1506 BridgeErrorCode::InvalidRequest,
1507 format!("reticle.import request rejected: {err}"),
1508 None,
1509 )
1510 }
1511 };
1512
1513 if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1514 return error(
1515 BridgeErrorCode::ResourceLimit,
1516 format!(
1517 "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1518 request.document.len()
1519 ),
1520 None,
1521 );
1522 }
1523
1524 let (reticle, report, format) = match request.format {
1525 ReticleDocumentFormat::Ventum => {
1526 match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1527 Ok((reticle, report)) => (
1528 reticle,
1529 json!({
1530 "dropped_elements": report.dropped_elements,
1531 "dropped_element_types": report
1532 .dropped_element_types
1533 .iter()
1534 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1535 .collect::<Vec<_>>(),
1536 "arcs": report
1541 .arcs
1542 .iter()
1543 .map(|arc| json!({
1544 "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1545 "radius_mil": arc.radius_mil,
1546 "start_degrees": arc.start_degrees,
1547 "end_degrees": arc.end_degrees,
1548 "sweep_degrees": arc.sweep_degrees,
1549 "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1550 "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1551 "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1552 }))
1553 .collect::<Vec<_>>(),
1554 "arcs_unresolved": report.arcs_unresolved,
1559 "circle_repeats_unreadable": report.circle_repeats_unreadable,
1560 }),
1561 "ventum",
1562 ),
1563 Err(err) => return reticle_error_envelope("reticle.import", &err),
1564 }
1565 }
1566 ReticleDocumentFormat::ReticleXml => {
1567 match crate::reticle_document_import::import_reticle_document_with_report(
1568 &request.document,
1569 ) {
1570 Ok((reticle, report)) => (
1571 reticle,
1572 json!({
1573 "drawing_elements_dropped": report.drawing_elements_dropped,
1574 "dropped_element_tags": report
1575 .dropped_element_tags
1576 .iter()
1577 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1578 .collect::<Vec<_>>(),
1579 "canvas_mil": report
1580 .canvas_mil
1581 .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1582 "zero_in_canvas_mil": report
1583 .zero_in_canvas_mil
1584 .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1585 }),
1586 "reticle_xml",
1587 ),
1588 Err(err) => return reticle_error_envelope("reticle.import", &err),
1589 }
1590 }
1591 };
1592
1593 match serde_json::to_value(&reticle) {
1594 Ok(imported) => success(
1595 "reticle.import",
1596 json!({
1597 "reticle": imported,
1598 "format": format,
1599 "mark_count": reticle.marks.len(),
1600 "report": report,
1601 }),
1602 ),
1603 Err(err) => error(
1604 BridgeErrorCode::InternalError,
1605 format!("failed to serialize imported reticle: {err}"),
1606 None,
1607 ),
1608 }
1609}
1610
1611#[cfg(test)]
1612mod tests {
1613 use super::*;
1614
1615 fn call(value: Value) -> Value {
1616 let raw = bridge_call(&value.to_string());
1617 serde_json::from_str(&raw).expect("bridge output must be valid JSON")
1618 }
1619
1620 #[test]
1621 fn capabilities_reports_commands_and_versions() {
1622 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1623 assert_eq!(out["ok"], true);
1624 assert_eq!(out["api_version"], 1);
1625 assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
1626 let commands: Vec<String> =
1627 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1628 assert!(commands.contains(&"solve".to_string()));
1629 assert!(commands.contains(&"meta.capabilities".to_string()));
1630 }
1631
1632 #[test]
1633 fn invalid_json_is_an_envelope_not_a_panic() {
1634 let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
1635 assert_eq!(out["ok"], false);
1636 assert_eq!(out["error"]["code"], "invalid_json");
1637 }
1638
1639 #[test]
1640 fn unknown_envelope_field_is_rejected() {
1641 let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
1642 assert_eq!(out["ok"], false);
1643 assert_eq!(out["error"]["code"], "invalid_json");
1644 }
1645
1646 #[test]
1647 fn unknown_command_lists_supported_ones() {
1648 let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
1651 assert_eq!(out["error"]["code"], "unknown_command");
1652 assert!(out["error"]["message"]
1653 .as_str()
1654 .unwrap()
1655 .contains("meta.capabilities"));
1656 }
1657
1658 #[test]
1659 fn wrong_api_version_is_rejected() {
1660 let out = call(json!({"api_version": 99, "command": "meta.version"}));
1661 assert_eq!(out["error"]["code"], "unsupported_api_version");
1662 }
1663
1664 #[test]
1665 fn oversize_request_is_a_resource_limit() {
1666 let big = format!(
1667 r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
1668 "x".repeat(MAX_REQUEST_BYTES)
1669 );
1670 let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
1671 assert_eq!(out["error"]["code"], "resource_limit");
1672 }
1673
1674 #[test]
1675 fn solve_without_payload_is_invalid_request() {
1676 let out = call(json!({"api_version": 1, "command": "solve"}));
1677 assert_eq!(out["error"]["code"], "invalid_request");
1678 }
1679
1680 #[test]
1681 fn profile_commands_without_payload_are_invalid_requests() {
1682 for command in ["profile.validate", "profile.normalize"] {
1683 let out = call(json!({"api_version": 1, "command": command}));
1684 assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
1685 assert!(
1686 out["error"]["message"]
1687 .as_str()
1688 .unwrap()
1689 .contains("ProfileData"),
1690 "{command}: {out}"
1691 );
1692 }
1693 }
1694
1695 #[test]
1696 fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
1697 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1698 let commands: Vec<String> =
1699 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1700 assert!(commands.contains(&"profile.validate".to_string()));
1701 assert!(commands.contains(&"profile.normalize".to_string()));
1702 assert_eq!(
1703 commands.contains(&"profile.import_a7p".to_string()),
1704 cfg!(feature = "profile-import"),
1705 "profile.import_a7p must be listed exactly when compiled in"
1706 );
1707 assert_eq!(
1708 commands.contains(&"bc5d.info".to_string()),
1709 cfg!(not(target_arch = "wasm32")),
1710 "bc5d.info must be listed exactly when the build has filesystem access"
1711 );
1712 }
1713
1714 #[cfg(not(target_arch = "wasm32"))]
1715 #[test]
1716 fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
1717 let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
1718 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1719
1720 let out = call(json!({
1721 "api_version": 1,
1722 "command": "bc5d.info",
1723 "request": {"path": "/nonexistent/bc5d_308.bin"}
1724 }));
1725 assert_eq!(out["error"]["code"], "command_failed", "{out}");
1726 assert!(
1727 out["error"]["message"]
1728 .as_str()
1729 .unwrap()
1730 .contains("not a usable BC5D table"),
1731 "{out}"
1732 );
1733 }
1734
1735 #[cfg(feature = "profile-import")]
1736 #[test]
1737 fn base64_decoder_round_trips_and_rejects_garbage() {
1738 for (text, bytes) in [
1740 ("", &b""[..]),
1741 ("Zg==", b"f"),
1742 ("Zm8=", b"fo"),
1743 ("Zm9v", b"foo"),
1744 ("Zm9vYg==", b"foob"),
1745 ("Zm9vYmE=", b"fooba"),
1746 ("Zm9vYmFy", b"foobar"),
1747 ] {
1748 assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1749 }
1750 assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1751 assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1752 assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1753 assert!(decode_base64("Zm9v!").is_err(), "non-alphabet byte is rejected");
1754 }
1755
1756 #[test]
1760 fn capabilities_gates_card_pdf_on_the_pdf_feature() {
1761 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1762 let commands: Vec<String> =
1763 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1764 assert_eq!(
1765 commands.contains(&"card.pdf".to_string()),
1766 cfg!(feature = "pdf"),
1767 "card.pdf must be listed exactly when compiled in: {out}"
1768 );
1769 let features: Vec<String> =
1770 serde_json::from_value(out["result"]["features"].clone()).unwrap();
1771 assert_eq!(
1772 features.contains(&"pdf".to_string()),
1773 cfg!(feature = "pdf"),
1774 "the command list and the feature list must agree: {out}"
1775 );
1776 }
1777
1778 #[cfg(not(feature = "pdf"))]
1779 #[test]
1780 fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
1781 let out = call(json!({
1782 "api_version": 1,
1783 "command": "card.pdf",
1784 "request": {
1785 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1786 "mass": 175.0, "diameter": 0.308,
1787 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
1788 }
1789 }));
1790 assert_eq!(out["error"]["code"], "unknown_command", "{out}");
1791 }
1792
1793 #[test]
1797 fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
1798 let out = call(json!({
1799 "api_version": 1,
1800 "command": "card.range_table",
1801 "request": {
1802 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
1803 "mass": 175.0, "diameter": 0.308,
1804 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
1805 "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
1806 }
1807 }));
1808 assert_eq!(out["ok"], true, "{out}");
1809 assert_eq!(out["result"]["kind"], "range_table", "{out}");
1810 }
1811
1812 #[cfg(feature = "pdf")]
1813 #[test]
1814 fn card_pdf_without_payload_is_invalid_request() {
1815 let out = call(json!({"api_version": 1, "command": "card.pdf"}));
1816 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1817 assert!(
1818 out["error"]["message"].as_str().unwrap().contains("card v1 document"),
1819 "{out}"
1820 );
1821 }
1822
1823 #[cfg(feature = "pdf")]
1827 #[test]
1828 fn pdf_output_cap_refuses_only_over_the_limit() {
1829 assert!(pdf_over_cap_error(0, 0, 0).is_none());
1830 assert!(
1831 pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
1832 "a document exactly at the cap fits"
1833 );
1834 let envelope: Value =
1835 serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
1836 .unwrap();
1837 assert_eq!(envelope["ok"], false);
1838 assert_eq!(envelope["error"]["code"], "resource_limit");
1839 let message = envelope["error"]["message"].as_str().unwrap();
1840 assert!(message.contains("dope card"), "{envelope}");
1841 assert!(message.contains("6 rows"), "{envelope}");
1843 assert!(message.contains("1 pages"), "{envelope}");
1844 for absent in ["coarsen", "shorten"] {
1845 assert!(!message.contains(absent), "{envelope}");
1846 }
1847 }
1848
1849 #[cfg(feature = "pdf")]
1850 #[test]
1851 fn base64_encoder_matches_the_rfc_4648_vectors() {
1852 for (bytes, text) in [
1853 (&b""[..], ""),
1854 (b"f", "Zg=="),
1855 (b"fo", "Zm8="),
1856 (b"foo", "Zm9v"),
1857 (b"foob", "Zm9vYg=="),
1858 (b"fooba", "Zm9vYmE="),
1859 (b"foobar", "Zm9vYmFy"),
1860 ] {
1861 assert_eq!(encode_base64(bytes), text, "{bytes:?}");
1862 }
1863 assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
1866 assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
1867 assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
1868 }
1869
1870 #[cfg(all(feature = "pdf", feature = "profile-import"))]
1873 #[test]
1874 fn base64_encode_decode_round_trips_arbitrary_bytes() {
1875 for len in 0..=32usize {
1876 let bytes: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(37).wrapping_add(11)).collect();
1877 let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
1878 assert_eq!(decoded, bytes, "len {len}");
1879 }
1880 }
1881
1882 #[test]
1883 fn solve_with_bad_schema_carries_typed_details() {
1884 let out = call(json!({
1885 "api_version": 1,
1886 "command": "solve",
1887 "request": {"schema_version": 1, "unknown_field": true}
1888 }));
1889 assert_eq!(out["error"]["code"], "command_failed");
1890 assert_eq!(out["error"]["details"]["status"], "error");
1892 }
1893
1894 fn hash_reticle() -> Value {
1901 json!({
1902 "name": "test cross",
1903 "focal_plane": "ffp",
1904 "reference_magnification": 1.0,
1905 "marks": [
1906 {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
1907 {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
1908 {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
1909 {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
1910 ],
1911 })
1912 }
1913
1914 #[test]
1915 fn capabilities_lists_the_reticle_family() {
1916 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1919 let commands: Vec<String> =
1920 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1921 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
1922 assert!(
1923 commands.contains(&command.to_string()),
1924 "{command} dispatches but meta.capabilities does not list it"
1925 );
1926 }
1927 }
1928
1929 #[test]
1930 fn hold_reports_the_nearest_mark_and_its_label() {
1931 let out = call(json!({
1932 "api_version": 1,
1933 "command": "reticle.hold",
1934 "request": {
1935 "reticle": hash_reticle(),
1936 "drop_mil": 2.1,
1937 "wind_mil": 0.0,
1938 "magnification": 10.0,
1939 },
1940 }));
1941 assert_eq!(out["ok"], true, "{out}");
1942 assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
1945 assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
1946 assert_eq!(out["result"]["hold"]["off_reticle"], false);
1947 assert_eq!(out["result"]["nearest_mark"]["index"], 1);
1948 assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
1949 assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
1950 }
1951
1952 #[test]
1953 fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
1954 let mut sfp = hash_reticle();
1958 sfp["focal_plane"] = json!("sfp");
1959 sfp["reference_magnification"] = json!(10.0);
1960
1961 let at_reference = call(json!({
1962 "api_version": 1, "command": "reticle.hold",
1963 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
1964 }));
1965 assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
1966 assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
1967 assert_eq!(
1968 at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
1969 2.0
1970 );
1971
1972 let zoomed_out = call(json!({
1975 "api_version": 1, "command": "reticle.hold",
1976 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
1977 }));
1978 assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
1979 assert_eq!(
1980 zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
1981 "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
1982 );
1983 assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
1984 assert_eq!(
1986 zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
1987 0.0
1988 );
1989
1990 let on_the_scaled_hash = call(json!({
1998 "api_version": 1, "command": "reticle.hold",
1999 "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2000 }));
2001 assert_eq!(
2002 on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2003 "as authored, the mark is at 2 mil"
2004 );
2005 assert_eq!(
2006 on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2007 "at 5x it subtends 4 mil, which is what the hold is measured against"
2008 );
2009 assert_eq!(
2010 on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2011 "the hold lands exactly on it"
2012 );
2013 }
2014
2015 #[test]
2016 fn a_hold_off_the_marked_part_says_so() {
2017 let out = call(json!({
2020 "api_version": 1, "command": "reticle.hold",
2021 "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2022 }));
2023 assert_eq!(out["ok"], true);
2024 assert_eq!(out["result"]["hold"]["off_reticle"], true);
2025 }
2026
2027 #[test]
2028 fn describe_builds_from_a_generator_and_reports_the_plane() {
2029 let out = call(json!({
2030 "api_version": 1, "command": "reticle.describe",
2031 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2032 }));
2033 assert_eq!(out["ok"], true, "{out}");
2034 assert_eq!(out["result"]["focal_plane"], "FFP");
2035 assert_eq!(out["result"]["magnification_dependent"], false);
2036 assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2037 assert!(out["result"]["scaled"].is_null());
2039 }
2040
2041 #[test]
2042 fn a_generated_reticle_can_be_made_sfp() {
2043 let out = call(json!({
2047 "api_version": 1, "command": "reticle.describe",
2048 "request": {
2049 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2050 "focal_plane": "sfp",
2051 "reference_magnification": 12.0,
2052 "magnification": 6.0,
2053 },
2054 }));
2055 assert_eq!(out["ok"], true, "{out}");
2056 assert_eq!(out["result"]["focal_plane"], "SFP");
2057 assert_eq!(out["result"]["magnification_dependent"], true);
2058 assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2059 }
2060
2061 #[test]
2062 fn a_bdc_ladder_carries_its_range_labels() {
2063 let out = call(json!({
2064 "api_version": 1, "command": "reticle.describe",
2065 "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2066 }));
2067 assert_eq!(out["ok"], true, "{out}");
2068 let marks = out["result"]["reticle"]["marks"]
2069 .as_array()
2070 .unwrap()
2071 .clone();
2072 let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2073 assert_eq!(labels, vec!["300 m", "400 m"]);
2075 }
2076
2077 #[test]
2078 fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2079 let out = call(json!({
2081 "api_version": 1, "command": "reticle.hold",
2082 "request": {
2083 "reticle": hash_reticle(),
2084 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2085 "drop_mil": 1.0,
2086 "magnification": 10.0,
2087 },
2088 }));
2089 assert_eq!(out["ok"], false);
2090 assert_eq!(out["error"]["code"], "invalid_request");
2091 assert!(out["error"]["message"]
2092 .as_str()
2093 .unwrap()
2094 .contains("not both"));
2095 }
2096
2097 #[test]
2098 fn naming_no_reticle_at_all_is_refused() {
2099 let out = call(json!({
2100 "api_version": 1, "command": "reticle.hold",
2101 "request": {"drop_mil": 1.0, "magnification": 10.0},
2102 }));
2103 assert_eq!(out["ok"], false);
2104 assert_eq!(out["error"]["code"], "invalid_request");
2105 }
2106
2107 #[test]
2108 fn plane_overrides_beside_a_full_reticle_are_refused() {
2109 let out = call(json!({
2112 "api_version": 1, "command": "reticle.describe",
2113 "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2114 }));
2115 assert_eq!(out["ok"], false);
2116 assert_eq!(out["error"]["code"], "invalid_request");
2117 }
2118
2119 #[test]
2120 fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2121 let out = call(json!({
2125 "api_version": 1, "command": "reticle.hold",
2126 "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2127 }));
2128 assert_eq!(out["ok"], false);
2129 assert_eq!(out["error"]["code"], "command_failed");
2130 assert_eq!(
2131 out["error"]["details"]["reason"],
2132 "non_positive_magnification"
2133 );
2134 assert_eq!(out["error"]["details"]["magnification"], 0.0);
2135 assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2136 }
2137
2138 #[test]
2139 fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2140 let mut empty = hash_reticle();
2143 empty["marks"] = json!([]);
2144 let out = call(json!({
2145 "api_version": 1, "command": "reticle.describe",
2146 "request": {"reticle": empty},
2147 }));
2148 assert_eq!(out["ok"], false);
2149 assert_eq!(out["error"]["details"]["reason"], "no_marks");
2150 }
2151
2152 #[test]
2153 fn an_unreadable_generator_parameter_names_the_parameter() {
2154 let out = call(json!({
2155 "api_version": 1, "command": "reticle.describe",
2156 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2157 }));
2158 assert_eq!(out["ok"], false);
2159 assert_eq!(
2160 out["error"]["details"]["reason"],
2161 "invalid_generator_parameter"
2162 );
2163 assert_eq!(out["error"]["details"]["parameter"], "spacing");
2164 }
2165
2166 #[test]
2167 fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2168 let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2174 {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2175 {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2176 {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2177
2178 let out = call(json!({
2179 "api_version": 1, "command": "reticle.import",
2180 "request": {"format": "ventum", "document": document},
2181 }));
2182 assert_eq!(out["ok"], true, "{out}");
2183 assert_eq!(out["result"]["format"], "ventum");
2184 assert_eq!(out["result"]["mark_count"], 78);
2185 assert_eq!(out["result"]["reticle"]["name"], "MBR");
2186 assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2187 assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2189 assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2190
2191 let held = call(json!({
2193 "api_version": 1, "command": "reticle.hold",
2194 "request": {
2195 "reticle": out["result"]["reticle"],
2196 "drop_mil": 8.0,
2197 "wind_mil": 2.0,
2198 "magnification": 10.0,
2199 },
2200 }));
2201 assert_eq!(held["ok"], true, "{held}");
2202 assert_eq!(held["result"]["hold"]["off_reticle"], false);
2203 assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2204 assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2205 assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2206 }
2207
2208 #[test]
2209 fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2210 let out = call(json!({
2211 "api_version": 1, "command": "reticle.import",
2212 "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2213 }));
2214 assert_eq!(out["ok"], false);
2215 assert_eq!(out["error"]["code"], "command_failed");
2216 }
2217
2218 #[test]
2219 fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2220 let out = call(json!({
2223 "api_version": 1, "command": "reticle.import",
2224 "request": {
2225 "format": "ventum",
2226 "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2227 },
2228 }));
2229 assert_eq!(out["ok"], false);
2230 assert_eq!(out["error"]["code"], "resource_limit");
2231 assert!(out["error"]["message"]
2232 .as_str()
2233 .unwrap()
2234 .contains("reticle document"));
2235 }
2236
2237 #[test]
2238 fn every_reticle_command_rejects_an_unknown_request_field() {
2239 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2240 let out = call(json!({
2241 "api_version": 1,
2242 "command": command,
2243 "request": {"nonsense": 1},
2244 }));
2245 assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2246 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2247 }
2248 }
2249
2250 #[test]
2251 fn every_reticle_command_rejects_a_missing_payload() {
2252 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2253 let out = call(json!({"api_version": 1, "command": command}));
2254 assert_eq!(out["ok"], false, "{command} accepted a null payload");
2255 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2256 }
2257 }
2258}