1#[cfg(feature = "ffi")]
164pub mod ffi;
165
166use serde::{Deserialize, Serialize};
167use serde_json::{json, Value};
168use std::panic::{catch_unwind, AssertUnwindSafe};
169
170pub const BRIDGE_API_VERSION: u32 = 1;
172
173pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
175
176const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
177
178fn command_names() -> Vec<&'static str> {
181 let mut names = vec![
182 "meta.capabilities",
183 "meta.version",
184 "solve",
185 "atmosphere.density_altitude",
186 "card.come_ups",
187 "card.range_table",
188 "card.wind",
189 ];
190 #[cfg(feature = "pdf")]
194 names.push("card.pdf");
195 names.extend(["profile.validate", "profile.normalize"]);
196 #[cfg(feature = "profile-import")]
197 names.push("profile.import_a7p");
198 #[cfg(feature = "profile-export")]
199 names.push("profile.export_a7p");
200 names.extend([
203 "reticle.catalog",
204 "reticle.describe",
205 "reticle.hold",
206 "reticle.holds",
207 "reticle.import",
208 ]);
209 names.extend([
210 "true.fit",
211 "true.wind",
212 "true.tall_target",
213 "true.dsf",
214 "true.plan",
215 "true.dial_plan",
216 ]);
217 #[cfg(not(target_arch = "wasm32"))]
220 names.push("bc5d.info");
221 names
222}
223
224fn compiled_features() -> Vec<&'static str> {
225 [
226 ("pdf", cfg!(feature = "pdf")),
227 ("profile-import", cfg!(feature = "profile-import")),
228 ("profile-export", cfg!(feature = "profile-export")),
229 ("online", cfg!(feature = "online")),
230 ]
231 .iter()
232 .filter(|(_, enabled)| *enabled)
233 .map(|(name, _)| *name)
234 .collect()
235}
236
237#[derive(Debug, Deserialize)]
238#[serde(deny_unknown_fields)]
239struct BridgeRequest {
240 api_version: u32,
241 command: String,
242 #[serde(default)]
243 request: Value,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
249#[serde(rename_all = "snake_case")]
250pub enum BridgeErrorCode {
251 InvalidJson,
252 UnsupportedApiVersion,
253 UnknownCommand,
254 InvalidRequest,
255 ResourceLimit,
256 CommandFailed,
257 InternalError,
258}
259
260fn success(command: &str, result: Value) -> String {
261 serialize_envelope(&json!({
262 "ok": true,
263 "api_version": BRIDGE_API_VERSION,
264 "engine_version": ENGINE_VERSION,
265 "command": command,
266 "result": result,
267 }))
268}
269
270fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
271 let mut error = json!({
272 "code": code,
273 "message": message.into(),
274 });
275 if let Some(details) = details {
276 error["details"] = details;
277 }
278 serialize_envelope(&json!({
279 "ok": false,
280 "api_version": BRIDGE_API_VERSION,
281 "engine_version": ENGINE_VERSION,
282 "error": error,
283 }))
284}
285
286fn serialize_envelope(value: &Value) -> String {
289 serde_json::to_string(value).unwrap_or_else(|_| {
290 format!(
291 r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
292 )
293 })
294}
295
296pub fn bridge_call(request_json: &str) -> String {
299 let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
300 guarded.unwrap_or_else(|_| {
301 error(
302 BridgeErrorCode::InternalError,
303 "bridge command failed unexpectedly",
304 None,
305 )
306 })
307}
308
309fn dispatch(request_json: &str) -> String {
310 if request_json.len() > MAX_REQUEST_BYTES {
311 return error(
312 BridgeErrorCode::ResourceLimit,
313 format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
314 None,
315 );
316 }
317
318 let request: BridgeRequest = match serde_json::from_str(request_json) {
319 Ok(request) => request,
320 Err(err) => {
321 return error(
322 BridgeErrorCode::InvalidJson,
323 format!("bridge request is not a valid envelope: {err}"),
324 None,
325 )
326 }
327 };
328
329 if request.api_version != BRIDGE_API_VERSION {
330 return error(
331 BridgeErrorCode::UnsupportedApiVersion,
332 format!(
333 "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
334 request.api_version
335 ),
336 None,
337 );
338 }
339
340 match request.command.as_str() {
341 "meta.capabilities" => success(
342 "meta.capabilities",
343 json!({
344 "engine_version": ENGINE_VERSION,
345 "bridge_api_version": BRIDGE_API_VERSION,
346 "commands": command_names(),
347 "features": compiled_features(),
348 "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
349 }),
350 ),
351 "meta.version" => success("meta.version", json!({ "engine_version": ENGINE_VERSION })),
352 "solve" => run_solve(&request.request),
353 "atmosphere.density_altitude" => run_atmosphere_density_altitude(&request.request),
354 "card.come_ups" => run_service(
355 &request.request,
356 "card.come_ups",
357 crate::card_service::come_ups_v1,
358 ),
359 "card.range_table" => run_service(
360 &request.request,
361 "card.range_table",
362 crate::card_service::range_table_v1,
363 ),
364 "card.wind" => run_service(
365 &request.request,
366 "card.wind",
367 crate::card_service::wind_card_v1,
368 ),
369 #[cfg(feature = "pdf")]
370 "card.pdf" => run_card_pdf(&request.request),
371 "profile.validate" => run_profile_validate(&request.request),
372 "profile.normalize" => run_profile_normalize(&request.request),
373 #[cfg(feature = "profile-import")]
374 "profile.import_a7p" => run_profile_import_a7p(&request.request),
375 #[cfg(feature = "profile-export")]
376 "profile.export_a7p" => run_profile_export_a7p(&request.request),
377 "reticle.catalog" => run_reticle_catalog(),
378 "reticle.describe" => run_reticle_describe(&request.request),
379 "reticle.hold" => run_reticle_hold(&request.request),
380 "reticle.holds" => run_reticle_holds(&request.request),
381 "reticle.import" => run_reticle_import(&request.request),
382 "true.fit" => run_service(
383 &request.request,
384 "true.fit",
385 crate::truing_uncertainty::run_uncertainty_truing_v1,
386 ),
387 "true.wind" => run_service(
388 &request.request,
389 "true.wind",
390 crate::truing_wind::solve_wind_truing,
391 ),
392 "true.tall_target" => run_service(
393 &request.request,
394 "true.tall_target",
395 crate::truing_service::tall_target_v1,
396 ),
397 "true.dsf" => run_service_detailed(
398 &request.request,
399 "true.dsf",
400 crate::truing_service::derive_dsf_point_v1,
401 crate::truing_service::DsfServiceErrorV1::failure_details,
402 ),
403 "true.plan" => run_service_detailed(
404 &request.request,
405 "true.plan",
406 crate::truing_plan::plan_truing_experiment_v1,
407 crate::truing_plan::TruingPlanErrorV1::failure_details,
408 ),
409 "true.dial_plan" => run_service_detailed(
410 &request.request,
411 "true.dial_plan",
412 crate::truing_service::dial_plan_v1,
413 crate::optic::OpticError::failure_details,
414 ),
415 #[cfg(not(target_arch = "wasm32"))]
416 "bc5d.info" => run_bc5d_info(&request.request),
417 other => error(
418 BridgeErrorCode::UnknownCommand,
419 format!(
420 "unknown command '{other}'; this build supports: {}",
421 command_names().join(", ")
422 ),
423 None,
424 ),
425 }
426}
427
428fn run_solve(inner: &Value) -> String {
433 if inner.is_null() {
434 return error(
435 BridgeErrorCode::InvalidRequest,
436 "'solve' requires a request payload (solve-json v1 document)",
437 None,
438 );
439 }
440 let inner_text = match serde_json::to_string(inner) {
441 Ok(text) => text,
442 Err(err) => {
443 return error(
444 BridgeErrorCode::InternalError,
445 format!("failed to re-serialize solve request: {err}"),
446 None,
447 )
448 }
449 };
450
451 let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
452 Ok(request) => request,
453 Err(envelope) => return command_error("solve request rejected", &envelope),
454 };
455
456 match crate::solve_v1(request) {
457 Ok(successful) => match serde_json::to_value(&successful) {
458 Ok(result) => success("solve", result),
459 Err(err) => error(
460 BridgeErrorCode::InternalError,
461 format!("failed to serialize solve result: {err}"),
462 None,
463 ),
464 },
465 Err(envelope) => command_error("solve failed", &envelope),
466 }
467}
468
469fn run_atmosphere_density_altitude(inner: &Value) -> String {
475 const COMMAND: &str = "atmosphere.density_altitude";
476 if inner.is_null() {
477 return error(
478 BridgeErrorCode::InvalidRequest,
479 "'atmosphere.density_altitude' requires a request payload ({\"atmosphere\": {...}})",
480 None,
481 );
482 }
483 let outcome = crate::atmosphere_service::decode_density_altitude_request_v1(inner)
484 .and_then(|request| crate::atmosphere_service::density_altitude_v1(&request));
485 match outcome {
486 Ok(response) => match serde_json::to_value(&response) {
487 Ok(result) => success(COMMAND, result),
488 Err(err) => error(
489 BridgeErrorCode::InternalError,
490 format!("failed to serialize {COMMAND} result: {err}"),
491 None,
492 ),
493 },
494 Err(envelope) => command_error("atmosphere.density_altitude request rejected", &envelope),
495 }
496}
497
498fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
502where
503 Req: serde::de::DeserializeOwned,
504 Resp: serde::Serialize,
505 E: std::fmt::Display,
506 F: FnOnce(&Req) -> Result<Resp, E>,
507{
508 if inner.is_null() {
509 return error(
510 BridgeErrorCode::InvalidRequest,
511 format!("'{command}' requires a request payload"),
512 None,
513 );
514 }
515 let request: Req = match serde_json::from_value(inner.clone()) {
516 Ok(request) => request,
517 Err(err) => {
518 return error(
519 BridgeErrorCode::InvalidRequest,
520 format!("{command} request rejected: {err}"),
521 None,
522 )
523 }
524 };
525 match service(&request) {
526 Ok(response) => match serde_json::to_value(&response) {
527 Ok(result) => success(command, result),
528 Err(err) => error(
529 BridgeErrorCode::InternalError,
530 format!("failed to serialize {command} result: {err}"),
531 None,
532 ),
533 },
534 Err(err) => error(
535 BridgeErrorCode::CommandFailed,
536 format!("{command} failed: {err}"),
537 None,
538 ),
539 }
540}
541
542fn run_service_detailed<Req, Resp, E, F, D>(
551 inner: &Value,
552 command: &'static str,
553 service: F,
554 details: D,
555) -> String
556where
557 Req: serde::de::DeserializeOwned,
558 Resp: serde::Serialize,
559 E: std::fmt::Display,
560 F: FnOnce(&Req) -> Result<Resp, E>,
561 D: FnOnce(&E) -> Option<Value>,
562{
563 if inner.is_null() {
564 return error(
565 BridgeErrorCode::InvalidRequest,
566 format!("'{command}' requires a request payload"),
567 None,
568 );
569 }
570 let request: Req = match serde_json::from_value(inner.clone()) {
571 Ok(request) => request,
572 Err(err) => {
573 return error(
574 BridgeErrorCode::InvalidRequest,
575 format!("{command} request rejected: {err}"),
576 None,
577 )
578 }
579 };
580 match service(&request) {
581 Ok(response) => match serde_json::to_value(&response) {
582 Ok(result) => success(command, result),
583 Err(err) => error(
584 BridgeErrorCode::InternalError,
585 format!("failed to serialize {command} result: {err}"),
586 None,
587 ),
588 },
589 Err(err) => {
590 let d = details(&err);
591 error(
592 BridgeErrorCode::CommandFailed,
593 format!("{command} failed: {err}"),
594 d,
595 )
596 }
597 }
598}
599
600#[cfg(feature = "pdf")]
614pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
615
616#[cfg(feature = "pdf")]
625fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
626 (byte_length > MAX_PDF_BYTES).then(|| {
627 error(
628 BridgeErrorCode::ResourceLimit,
629 format!(
630 "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
631 ({row_count} rows, {page_count} pages)"
632 ),
633 None,
634 )
635 })
636}
637
638#[cfg(feature = "pdf")]
645const STORED_CARD_KEY: &str = "stored_card";
646
647#[cfg(feature = "pdf")]
688fn run_card_pdf(inner: &Value) -> String {
689 use crate::card_service::CardServiceError;
690
691 if inner.is_null() {
692 return error(
693 BridgeErrorCode::InvalidRequest,
694 "'card.pdf' requires a request payload (card v1 document)",
695 None,
696 );
697 }
698 let mut payload = inner.clone();
699 let stored_value = payload
700 .as_object_mut()
701 .and_then(|object| object.remove(STORED_CARD_KEY))
702 .filter(|value| !value.is_null());
703 let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
704 Ok(request) => request,
705 Err(err) => {
706 return error(
707 BridgeErrorCode::InvalidRequest,
708 format!("card.pdf request rejected: {err}"),
709 None,
710 )
711 }
712 };
713 let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
714 Some(value) => match serde_json::from_value(value) {
715 Ok(stored) => Some(stored),
716 Err(err) => {
717 return error(
718 BridgeErrorCode::InvalidRequest,
719 format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
720 None,
721 )
722 }
723 },
724 None => None,
725 };
726
727 let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
728 Ok(card) => card,
729 Err(err @ CardServiceError::TooLarge(_)) => {
732 return error(
733 BridgeErrorCode::ResourceLimit,
734 format!("card.pdf refused: {err}"),
735 None,
736 )
737 }
738 Err(err) => {
739 return error(
740 BridgeErrorCode::CommandFailed,
741 format!("card.pdf failed: {err}"),
742 None,
743 )
744 }
745 };
746 let byte_length = card.pdf_bytes.len();
747 if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
748 return envelope;
749 }
750 let mut response = json!({
751 "pdf_base64": encode_base64(&card.pdf_bytes),
752 "byte_length": byte_length,
753 "page_count": card.page_count,
754 "row_count": card.row_count,
755 "kind": crate::card_service::PDF_CARD_KIND,
756 "source": card.source.as_str(),
757 "unprintable_title_chars": card.unprintable_title_chars,
758 });
759 if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
765 object.insert(
766 "truncation".to_string(),
767 json!({
768 "requested_end": truncation.requested_end,
769 "last_row": truncation.last_row,
770 "reach": truncation.reach,
771 }),
772 );
773 }
774 success("card.pdf", response)
775}
776
777#[cfg(any(feature = "pdf", feature = "profile-export"))]
788fn encode_base64(bytes: &[u8]) -> String {
789 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
790 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
791 for chunk in bytes.chunks(3) {
792 let triple = (u32::from(chunk[0]) << 16)
793 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
794 | u32::from(chunk.get(2).copied().unwrap_or(0));
795 out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
796 out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
797 out.push(if chunk.len() > 1 {
799 char::from(ALPHABET[(triple >> 6) as usize & 63])
800 } else {
801 '='
802 });
803 out.push(if chunk.len() > 2 {
804 char::from(ALPHABET[triple as usize & 63])
805 } else {
806 '='
807 });
808 }
809 out
810}
811
812fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
814 let details = serde_json::to_value(typed).ok();
815 error(BridgeErrorCode::CommandFailed, message, details)
816}
817
818fn decode_profile_document(
823 inner: &Value,
824 command: &'static str,
825) -> Result<crate::profile::ProfileData, String> {
826 if inner.is_null() {
827 return Err(error(
828 BridgeErrorCode::InvalidRequest,
829 format!("'{command}' requires a request payload (a ProfileData JSON document)"),
830 None,
831 ));
832 }
833 serde_json::from_value(inner.clone()).map_err(|err| {
834 error(
835 BridgeErrorCode::InvalidRequest,
836 format!("{command} request is not a ProfileData document: {err}"),
837 None,
838 )
839 })
840}
841
842fn run_profile_validate(inner: &Value) -> String {
849 let profile = match decode_profile_document(inner, "profile.validate") {
850 Ok(profile) => profile,
851 Err(envelope) => return envelope,
852 };
853 let warnings = profile.validation_warnings();
854 match serde_json::to_value(&profile) {
855 Ok(normalized) => success(
856 "profile.validate",
857 json!({
858 "valid": warnings.is_empty(),
859 "warnings": warnings,
860 "normalized": normalized,
861 }),
862 ),
863 Err(err) => error(
864 BridgeErrorCode::InternalError,
865 format!("failed to serialize normalized profile: {err}"),
866 None,
867 ),
868 }
869}
870
871fn run_profile_normalize(inner: &Value) -> String {
877 let profile = match decode_profile_document(inner, "profile.normalize") {
878 Ok(profile) => profile,
879 Err(envelope) => return envelope,
880 };
881 match serde_json::to_value(&profile) {
882 Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
883 Err(err) => error(
884 BridgeErrorCode::InternalError,
885 format!("failed to serialize normalized profile: {err}"),
886 None,
887 ),
888 }
889}
890
891#[cfg(not(target_arch = "wasm32"))]
893#[derive(Debug, Deserialize)]
894#[serde(deny_unknown_fields)]
895struct Bc5dInfoRequest {
896 path: String,
897}
898
899#[cfg(not(target_arch = "wasm32"))]
919fn run_bc5d_info(inner: &Value) -> String {
920 if inner.is_null() {
921 return error(
922 BridgeErrorCode::InvalidRequest,
923 "'bc5d.info' requires a request payload ({\"path\": ...})",
924 None,
925 );
926 }
927 let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
928 Ok(request) => request,
929 Err(err) => {
930 return error(
931 BridgeErrorCode::InvalidRequest,
932 format!("bc5d.info request rejected: {err}"),
933 None,
934 )
935 }
936 };
937
938 let table =
939 match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
940 Ok(table) => table,
941 Err(err) => {
942 return error(
943 BridgeErrorCode::CommandFailed,
944 format!("bc5d.info: not a usable BC5D table: {err}"),
945 None,
946 )
947 }
948 };
949
950 let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
951 let (weight_lo, weight_hi) = table.weight_range();
952 let (vel_lo, vel_hi) = table.velocity_range();
953 success(
954 "bc5d.info",
955 json!({
956 "valid": true,
959 "crc_ok": true,
960 "format_version": table.version(),
961 "caliber": table.caliber(),
962 "caliber_key": table.caliber_key(),
964 "api_version": table.api_version(),
965 "generated_timestamp": table.timestamp(),
966 "bins": {
968 "weight": weight,
969 "bc": bc,
970 "muzzle_velocity": muzzle_vel,
971 "current_velocity": current_vel,
972 "drag_types": drag_types,
973 },
974 "total_cells": table.total_cells(),
975 "weight_range_grains": [weight_lo, weight_hi],
976 "velocity_range_fps": [vel_lo, vel_hi],
977 }),
978 )
979}
980
981#[cfg(feature = "profile-import")]
985pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
986
987#[cfg(feature = "profile-import")]
993#[derive(Debug, Deserialize)]
994#[serde(deny_unknown_fields)]
995struct ProfileImportA7pRequest {
996 a7p_base64: String,
997 #[serde(default)]
998 zero_click: Option<String>,
999 #[serde(default)]
1000 strict: bool,
1001}
1002
1003#[cfg(feature = "profile-import")]
1013fn run_profile_import_a7p(inner: &Value) -> String {
1014 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1015
1016 if inner.is_null() {
1017 return error(
1018 BridgeErrorCode::InvalidRequest,
1019 "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
1020 None,
1021 );
1022 }
1023 let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
1024 Ok(request) => request,
1025 Err(err) => {
1026 return error(
1027 BridgeErrorCode::InvalidRequest,
1028 format!("profile.import_a7p request rejected: {err}"),
1029 None,
1030 )
1031 }
1032 };
1033
1034 let zero_click = match request.zero_click.as_deref() {
1035 Some(raw) => match crate::adjustment::parse_click_value(raw) {
1036 Ok(click) => Some(click),
1037 Err(err) => {
1038 return error(
1039 BridgeErrorCode::InvalidRequest,
1040 format!("profile.import_a7p zero_click: {err}"),
1041 None,
1042 )
1043 }
1044 },
1045 None => None,
1046 };
1047
1048 let bytes = match decode_base64(&request.a7p_base64) {
1049 Ok(bytes) => bytes,
1050 Err(err) => {
1051 return error(
1052 BridgeErrorCode::InvalidRequest,
1053 format!("profile.import_a7p a7p_base64: {err}"),
1054 None,
1055 )
1056 }
1057 };
1058 if bytes.len() > MAX_A7P_DECODED_BYTES {
1059 return error(
1060 BridgeErrorCode::ResourceLimit,
1061 format!(
1062 "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
1063 bytes.len()
1064 ),
1065 None,
1066 );
1067 }
1068
1069 let doc = match parse_a7p(&bytes) {
1070 Ok(doc) => doc,
1071 Err(err) => {
1072 return error(
1073 BridgeErrorCode::CommandFailed,
1074 format!("not a usable .a7p file: {err}"),
1075 None,
1076 )
1077 }
1078 };
1079 if request.strict {
1082 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
1083 return error(
1084 BridgeErrorCode::CommandFailed,
1085 format!(
1086 "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
1087 ),
1088 None,
1089 );
1090 }
1091 }
1092
1093 let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1094 Ok(outcome) => outcome,
1095 Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1096 };
1097 let unknown_fields: Vec<Value> = doc
1098 .unknown_fields
1099 .iter()
1100 .map(|u| json!({ "context": u.context, "number": u.number }))
1101 .collect();
1102 match serde_json::to_value(&outcome.profile) {
1103 Ok(profile) => success(
1104 "profile.import_a7p",
1105 json!({
1106 "profile": profile,
1107 "warnings": outcome.report.warnings,
1108 "mapped": outcome.report.mapped,
1109 "unmapped": outcome.report.unmapped,
1110 "unknown_fields": unknown_fields,
1111 }),
1112 ),
1113 Err(err) => error(
1114 BridgeErrorCode::InternalError,
1115 format!("failed to serialize imported profile: {err}"),
1116 None,
1117 ),
1118 }
1119}
1120
1121#[cfg(feature = "profile-export")]
1143fn run_profile_export_a7p(inner: &Value) -> String {
1144 use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
1145
1146 let profile = match decode_profile_document(inner, "profile.export_a7p") {
1147 Ok(profile) => profile,
1148 Err(envelope) => return envelope,
1149 };
1150 let export = match export_a7p(&profile) {
1151 Ok(export) => export,
1152 Err(err) => {
1153 let details = match &err {
1154 A7pExportError::Units(_) => json!({ "reason": "units" }),
1155 A7pExportError::DragModel(model) => {
1156 json!({ "reason": "drag_model", "drag_model": model })
1157 }
1158 A7pExportError::Field { field, .. } => {
1159 json!({ "reason": "field", "field": field })
1160 }
1161 };
1162 return error(
1163 BridgeErrorCode::CommandFailed,
1164 err.to_string(),
1165 Some(details),
1166 );
1167 }
1168 };
1169 let not_carried = match serde_json::to_value(&export.not_carried) {
1170 Ok(value) => value,
1171 Err(err) => {
1172 return error(
1173 BridgeErrorCode::InternalError,
1174 format!("failed to serialize the not-carried list: {err}"),
1175 None,
1176 )
1177 }
1178 };
1179 success(
1180 "profile.export_a7p",
1181 json!({
1182 "a7p_base64": encode_base64(&export.bytes),
1183 "byte_length": export.bytes.len(),
1184 "carried_fields": CARRIED_FIELDS,
1185 "not_carried": not_carried,
1186 "dropped_fields": export.dropped_fields(),
1187 "warnings": export.warnings,
1188 }),
1189 )
1190}
1191
1192#[cfg(feature = "profile-import")]
1200fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1201 fn sextet(c: u8) -> Result<u32, String> {
1202 match c {
1203 b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1204 b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1205 b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1206 b'+' => Ok(62),
1207 b'/' => Ok(63),
1208 _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1209 }
1210 }
1211 let bytes = input.as_bytes();
1212 let data = match bytes {
1213 [rest @ .., b'=', b'='] => rest,
1214 [rest @ .., b'='] => rest,
1215 _ => bytes,
1216 };
1217 if data.contains(&b'=') {
1218 return Err("'=' is only valid as trailing padding".to_string());
1219 }
1220 if data.len() % 4 == 1 {
1221 return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1222 }
1223 let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1224 let mut acc: u32 = 0;
1225 let mut bits: u32 = 0;
1226 for &c in data {
1227 acc = (acc << 6) | sextet(c)?;
1228 bits += 6;
1229 if bits >= 8 {
1230 bits -= 8;
1231 out.push((acc >> bits) as u8);
1232 }
1233 }
1234 Ok(out)
1235}
1236
1237#[derive(Debug, Deserialize)]
1247#[serde(tag = "kind", rename_all = "snake_case")]
1248enum ReticleGenerator {
1249 MilGrid { spacing_mil: f64, extent_mil: f64 },
1251 Tree {
1253 rows: usize,
1254 row_spacing_mil: f64,
1255 spread_step_mil: f64,
1256 },
1257 Bdc { drops: Vec<(f64, f64)> },
1262}
1263
1264#[derive(Debug, Default, Deserialize)]
1272#[serde(deny_unknown_fields)]
1273struct ReticlePlaneOverride {
1274 #[serde(default)]
1275 focal_plane: Option<crate::reticle::FocalPlane>,
1276 #[serde(default)]
1277 reference_magnification: Option<f64>,
1278}
1279
1280fn resolve_reticle(
1284 command: &'static str,
1285 reticle: Option<crate::reticle::ReticleDescription>,
1286 generator: Option<ReticleGenerator>,
1287 catalog: Option<String>,
1288 plane: &ReticlePlaneOverride,
1289) -> Result<crate::reticle::ReticleDescription, String> {
1290 use crate::reticle::ReticleDescription;
1291
1292 let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1295 .iter()
1296 .filter(|named| **named)
1297 .count();
1298 if named > 1 {
1299 return Err(error(
1300 BridgeErrorCode::InvalidRequest,
1301 format!(
1302 "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1303 supplying more than one is a caller bug rather than a preference"
1304 ),
1305 None,
1306 ));
1307 }
1308
1309 let reticle = match (reticle, catalog) {
1311 (reticle, None) => reticle,
1312 (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1313 Some(Ok(built)) => Some(built),
1314 Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1315 None => {
1318 return Err(error(
1319 BridgeErrorCode::InvalidRequest,
1320 format!(
1321 "{command}: no catalog reticle with id {id:?}; \
1322 'reticle.catalog' lists what this build has"
1323 ),
1324 None,
1325 ))
1326 }
1327 },
1328 };
1329
1330 let mut described = match (reticle, generator) {
1331 (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1332 (None, None) => {
1333 return Err(error(
1334 BridgeErrorCode::InvalidRequest,
1335 format!(
1336 "{command} requires one of 'reticle' (a full description), \
1337 'generator' or 'catalog'"
1338 ),
1339 None,
1340 ))
1341 }
1342 (Some(reticle), None) => {
1343 if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1344 return Err(error(
1345 BridgeErrorCode::InvalidRequest,
1346 format!(
1347 "{command}: 'focal_plane' and 'reference_magnification' override a \
1348 GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1349 ),
1350 None,
1351 ));
1352 }
1353 reticle
1354 }
1355 (None, Some(generator)) => {
1356 let built = match generator {
1357 ReticleGenerator::MilGrid {
1358 spacing_mil,
1359 extent_mil,
1360 } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1361 ReticleGenerator::Tree {
1362 rows,
1363 row_spacing_mil,
1364 spread_step_mil,
1365 } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1366 ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1367 };
1368 match built {
1369 Ok(built) => built,
1370 Err(err) => return Err(reticle_error_envelope(command, &err)),
1371 }
1372 }
1373 };
1374
1375 if let Some(focal_plane) = plane.focal_plane {
1376 described.focal_plane = focal_plane;
1377 }
1378 if let Some(reference_magnification) = plane.reference_magnification {
1379 described.reference_magnification = reference_magnification;
1380 }
1381 Ok(described)
1382}
1383
1384fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1392 use crate::reticle::ReticleError;
1393
1394 let mut details = json!({ "reason": match err {
1395 ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1396 ReticleError::NonPositiveReferenceMagnification { .. } => {
1397 "non_positive_reference_magnification"
1398 }
1399 ReticleError::NoMarks => "no_marks",
1400 ReticleError::TooManyMarks { .. } => "too_many_marks",
1401 ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1402 ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1403 ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1404 ReticleError::InvalidSpec(_) => "invalid_spec",
1405 }});
1406
1407 match err {
1410 ReticleError::NonPositiveMagnification { magnification } => {
1411 details["magnification"] = json!(magnification);
1412 }
1413 ReticleError::NonPositiveReferenceMagnification {
1414 reference_magnification,
1415 } => {
1416 details["reference_magnification"] = json!(reference_magnification);
1417 }
1418 ReticleError::TooManyMarks { count, max } => {
1419 details["count"] = json!(count);
1420 details["max"] = json!(max);
1421 }
1422 ReticleError::NonFiniteMark { index } => {
1423 details["index"] = json!(index);
1424 }
1425 ReticleError::InvalidGeneratorParameter {
1426 parameter,
1427 value,
1428 rule,
1429 } => {
1430 details["parameter"] = json!(parameter);
1431 details["value"] = json!(value);
1432 details["rule"] = json!(rule);
1433 }
1434 ReticleError::NoMarks
1435 | ReticleError::NonFiniteHold { .. }
1436 | ReticleError::InvalidSpec(_) => {}
1437 }
1438 details["command"] = json!(command);
1439
1440 error(
1441 BridgeErrorCode::CommandFailed,
1442 err.to_string(),
1443 Some(details),
1444 )
1445}
1446
1447fn nearest_mark_value(
1455 reticle: &crate::reticle::ReticleDescription,
1456 hold: &crate::reticle::ReticleHold,
1457) -> Value {
1458 match hold
1459 .nearest_mark
1460 .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1461 {
1462 Some((index, mark)) => json!({
1463 "index": index,
1464 "kind": mark.kind.as_str(),
1465 "label": mark.label,
1466 "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1467 "true_angular": {
1468 "down_mil": mark.down_mil * hold.mark_scale,
1469 "right_mil": mark.right_mil * hold.mark_scale,
1470 },
1471 }),
1472 None => Value::Null,
1473 }
1474}
1475
1476fn run_reticle_catalog() -> String {
1487 let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1488 .into_iter()
1489 .map(|entry| {
1490 let mark_count = crate::reticle_catalog::by_id(entry.id)
1494 .and_then(|built| built.ok())
1495 .map(|reticle| reticle.marks.len());
1496 json!({
1497 "id": entry.id,
1498 "display_name": entry.display_name,
1499 "source": entry.source,
1500 "notes": entry.notes,
1501 "mark_count": mark_count,
1502 })
1503 })
1504 .collect();
1505 success("reticle.catalog", json!({ "reticles": reticles }))
1506}
1507
1508#[derive(Debug, Deserialize)]
1509#[serde(deny_unknown_fields)]
1510struct ReticleDescribeRequest {
1511 #[serde(default)]
1512 reticle: Option<crate::reticle::ReticleDescription>,
1513 #[serde(default)]
1514 generator: Option<ReticleGenerator>,
1515 #[serde(default)]
1518 catalog: Option<String>,
1519 #[serde(default)]
1520 focal_plane: Option<crate::reticle::FocalPlane>,
1521 #[serde(default)]
1522 reference_magnification: Option<f64>,
1523 #[serde(default)]
1526 magnification: Option<f64>,
1527}
1528
1529fn run_reticle_describe(inner: &Value) -> String {
1539 if inner.is_null() {
1540 return error(
1541 BridgeErrorCode::InvalidRequest,
1542 "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1543 None,
1544 );
1545 }
1546 let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1547 Ok(request) => request,
1548 Err(err) => {
1549 return error(
1550 BridgeErrorCode::InvalidRequest,
1551 format!("reticle.describe request rejected: {err}"),
1552 None,
1553 )
1554 }
1555 };
1556
1557 let plane = ReticlePlaneOverride {
1558 focal_plane: request.focal_plane,
1559 reference_magnification: request.reference_magnification,
1560 };
1561 let reticle = match resolve_reticle(
1562 "reticle.describe",
1563 request.reticle,
1564 request.generator,
1565 request.catalog,
1566 &plane,
1567 ) {
1568 Ok(reticle) => reticle,
1569 Err(envelope) => return envelope,
1570 };
1571
1572 if let Err(err) = reticle.validate() {
1575 return reticle_error_envelope("reticle.describe", &err);
1576 }
1577
1578 let scaled = match request.magnification {
1579 Some(magnification) => match reticle.scaled_marks(magnification) {
1580 Ok(marks) => json!({
1581 "magnification": magnification,
1582 "mark_scale": reticle.mark_scale(magnification),
1583 "marks": marks
1584 .iter()
1585 .map(|mark| json!({
1586 "down_mil": mark.down_mil,
1587 "right_mil": mark.right_mil,
1588 }))
1589 .collect::<Vec<_>>(),
1590 }),
1591 Err(err) => return reticle_error_envelope("reticle.describe", &err),
1592 },
1593 None => Value::Null,
1594 };
1595
1596 match serde_json::to_value(&reticle) {
1597 Ok(described) => success(
1598 "reticle.describe",
1599 json!({
1600 "reticle": described,
1601 "focal_plane": reticle.focal_plane.label(),
1602 "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1603 "mark_count": reticle.marks.len(),
1604 "scaled": scaled,
1605 }),
1606 ),
1607 Err(err) => error(
1608 BridgeErrorCode::InternalError,
1609 format!("failed to serialize reticle description: {err}"),
1610 None,
1611 ),
1612 }
1613}
1614
1615#[derive(Debug, Deserialize)]
1616#[serde(deny_unknown_fields)]
1617struct ReticleHoldRequest {
1618 #[serde(default)]
1619 reticle: Option<crate::reticle::ReticleDescription>,
1620 #[serde(default)]
1621 generator: Option<ReticleGenerator>,
1622 #[serde(default)]
1625 catalog: Option<String>,
1626 #[serde(default)]
1627 focal_plane: Option<crate::reticle::FocalPlane>,
1628 #[serde(default)]
1629 reference_magnification: Option<f64>,
1630 drop_mil: f64,
1633 #[serde(default)]
1635 wind_mil: f64,
1636 magnification: f64,
1640}
1641
1642fn run_reticle_hold(inner: &Value) -> String {
1658 if inner.is_null() {
1659 return error(
1660 BridgeErrorCode::InvalidRequest,
1661 "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1662 None,
1663 );
1664 }
1665 let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1666 Ok(request) => request,
1667 Err(err) => {
1668 return error(
1669 BridgeErrorCode::InvalidRequest,
1670 format!("reticle.hold request rejected: {err}"),
1671 None,
1672 )
1673 }
1674 };
1675
1676 let plane = ReticlePlaneOverride {
1677 focal_plane: request.focal_plane,
1678 reference_magnification: request.reference_magnification,
1679 };
1680 let reticle = match resolve_reticle(
1681 "reticle.hold",
1682 request.reticle,
1683 request.generator,
1684 request.catalog,
1685 &plane,
1686 ) {
1687 Ok(reticle) => reticle,
1688 Err(envelope) => return envelope,
1689 };
1690
1691 let hold = match crate::reticle::hold_point_in_reticle(
1692 request.drop_mil,
1693 request.wind_mil,
1694 request.magnification,
1695 &reticle,
1696 ) {
1697 Ok(hold) => hold,
1698 Err(err) => return reticle_error_envelope("reticle.hold", &err),
1699 };
1700
1701 let nearest = nearest_mark_value(&reticle, &hold);
1702 match serde_json::to_value(&hold) {
1703 Ok(hold_value) => success(
1704 "reticle.hold",
1705 json!({
1706 "hold": hold_value,
1707 "nearest_mark": nearest,
1708 }),
1709 ),
1710 Err(err) => error(
1711 BridgeErrorCode::InternalError,
1712 format!("failed to serialize reticle hold: {err}"),
1713 None,
1714 ),
1715 }
1716}
1717
1718pub const MAX_RETICLE_HOLDS: usize = 4096;
1723
1724#[derive(Debug, Deserialize)]
1725#[serde(deny_unknown_fields)]
1726struct ReticleHoldsEntry {
1727 drop_mil: f64,
1728 #[serde(default)]
1729 wind_mil: f64,
1730}
1731
1732#[derive(Debug, Deserialize)]
1733#[serde(deny_unknown_fields)]
1734struct ReticleHoldsRequest {
1735 #[serde(default)]
1736 reticle: Option<crate::reticle::ReticleDescription>,
1737 #[serde(default)]
1738 generator: Option<ReticleGenerator>,
1739 #[serde(default)]
1740 catalog: Option<String>,
1741 #[serde(default)]
1742 focal_plane: Option<crate::reticle::FocalPlane>,
1743 #[serde(default)]
1744 reference_magnification: Option<f64>,
1745 magnification: f64,
1746 holds: Vec<ReticleHoldsEntry>,
1749}
1750
1751fn run_reticle_holds(inner: &Value) -> String {
1768 if inner.is_null() {
1769 return error(
1770 BridgeErrorCode::InvalidRequest,
1771 "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1772 None,
1773 );
1774 }
1775 let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1776 Ok(request) => request,
1777 Err(err) => {
1778 return error(
1779 BridgeErrorCode::InvalidRequest,
1780 format!("reticle.holds request rejected: {err}"),
1781 None,
1782 )
1783 }
1784 };
1785
1786 if request.holds.len() > MAX_RETICLE_HOLDS {
1787 return error(
1788 BridgeErrorCode::ResourceLimit,
1789 format!(
1790 "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1791 request.holds.len()
1792 ),
1793 None,
1794 );
1795 }
1796
1797 let plane = ReticlePlaneOverride {
1798 focal_plane: request.focal_plane,
1799 reference_magnification: request.reference_magnification,
1800 };
1801 let reticle = match resolve_reticle(
1802 "reticle.holds",
1803 request.reticle,
1804 request.generator,
1805 request.catalog,
1806 &plane,
1807 ) {
1808 Ok(reticle) => reticle,
1809 Err(envelope) => return envelope,
1810 };
1811
1812 let mut placed = Vec::with_capacity(request.holds.len());
1813 let mut scale = 1.0;
1814 for entry in &request.holds {
1815 let hold = match crate::reticle::hold_point_in_reticle(
1816 entry.drop_mil,
1817 entry.wind_mil,
1818 request.magnification,
1819 &reticle,
1820 ) {
1821 Ok(hold) => hold,
1822 Err(err) => return reticle_error_envelope("reticle.holds", &err),
1829 };
1830 scale = hold.mark_scale;
1831 let nearest = nearest_mark_value(&reticle, &hold);
1832 match serde_json::to_value(&hold) {
1833 Ok(mut value) => {
1834 if let Some(object) = value.as_object_mut() {
1836 object.remove("mark_scale");
1837 }
1838 placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1839 }
1840 Err(err) => {
1841 return error(
1842 BridgeErrorCode::InternalError,
1843 format!("failed to serialize reticle hold: {err}"),
1844 None,
1845 )
1846 }
1847 }
1848 }
1849
1850 success(
1851 "reticle.holds",
1852 json!({ "mark_scale": scale, "holds": placed }),
1853 )
1854}
1855
1856pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1864
1865#[derive(Debug, Deserialize)]
1866#[serde(rename_all = "snake_case")]
1867enum ReticleDocumentFormat {
1868 Ventum,
1870 ReticleXml,
1872}
1873
1874#[derive(Debug, Deserialize)]
1875#[serde(deny_unknown_fields)]
1876struct ReticleImportRequest {
1877 format: ReticleDocumentFormat,
1878 document: String,
1880}
1881
1882fn run_reticle_import(inner: &Value) -> String {
1891 if inner.is_null() {
1892 return error(
1893 BridgeErrorCode::InvalidRequest,
1894 "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1895 None,
1896 );
1897 }
1898 let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1899 Ok(request) => request,
1900 Err(err) => {
1901 return error(
1902 BridgeErrorCode::InvalidRequest,
1903 format!("reticle.import request rejected: {err}"),
1904 None,
1905 )
1906 }
1907 };
1908
1909 if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1910 return error(
1911 BridgeErrorCode::ResourceLimit,
1912 format!(
1913 "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1914 request.document.len()
1915 ),
1916 None,
1917 );
1918 }
1919
1920 let (reticle, report, format) = match request.format {
1921 ReticleDocumentFormat::Ventum => {
1922 match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1923 Ok((reticle, report)) => (
1924 reticle,
1925 json!({
1926 "dropped_elements": report.dropped_elements,
1927 "dropped_element_types": report
1928 .dropped_element_types
1929 .iter()
1930 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1931 .collect::<Vec<_>>(),
1932 "arcs": report
1937 .arcs
1938 .iter()
1939 .map(|arc| json!({
1940 "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1941 "radius_mil": arc.radius_mil,
1942 "start_degrees": arc.start_degrees,
1943 "end_degrees": arc.end_degrees,
1944 "sweep_degrees": arc.sweep_degrees,
1945 "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1946 "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1947 "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1948 }))
1949 .collect::<Vec<_>>(),
1950 "arcs_unresolved": report.arcs_unresolved,
1955 "circle_repeats_unreadable": report.circle_repeats_unreadable,
1956 }),
1957 "ventum",
1958 ),
1959 Err(err) => return reticle_error_envelope("reticle.import", &err),
1960 }
1961 }
1962 ReticleDocumentFormat::ReticleXml => {
1963 match crate::reticle_document_import::import_reticle_document_with_report(
1964 &request.document,
1965 ) {
1966 Ok((reticle, report)) => (
1967 reticle,
1968 json!({
1969 "drawing_elements_dropped": report.drawing_elements_dropped,
1970 "dropped_element_tags": report
1971 .dropped_element_tags
1972 .iter()
1973 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1974 .collect::<Vec<_>>(),
1975 "canvas_mil": report
1976 .canvas_mil
1977 .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1978 "zero_in_canvas_mil": report
1979 .zero_in_canvas_mil
1980 .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1981 }),
1982 "reticle_xml",
1983 ),
1984 Err(err) => return reticle_error_envelope("reticle.import", &err),
1985 }
1986 }
1987 };
1988
1989 match serde_json::to_value(&reticle) {
1990 Ok(imported) => success(
1991 "reticle.import",
1992 json!({
1993 "reticle": imported,
1994 "format": format,
1995 "mark_count": reticle.marks.len(),
1996 "report": report,
1997 }),
1998 ),
1999 Err(err) => error(
2000 BridgeErrorCode::InternalError,
2001 format!("failed to serialize imported reticle: {err}"),
2002 None,
2003 ),
2004 }
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009 use super::*;
2010
2011 fn call(value: Value) -> Value {
2012 let raw = bridge_call(&value.to_string());
2013 serde_json::from_str(&raw).expect("bridge output must be valid JSON")
2014 }
2015
2016 #[test]
2017 fn capabilities_reports_commands_and_versions() {
2018 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2019 assert_eq!(out["ok"], true);
2020 assert_eq!(out["api_version"], 1);
2021 assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
2022 let commands: Vec<String> =
2023 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2024 assert!(commands.contains(&"solve".to_string()));
2025 assert!(commands.contains(&"meta.capabilities".to_string()));
2026 }
2027
2028 #[test]
2029 fn density_altitude_is_listed_right_after_solve() {
2030 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2031 let commands: Vec<String> =
2032 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2033 let solve = commands.iter().position(|c| c == "solve").unwrap();
2034 assert_eq!(
2035 commands.get(solve + 1).map(String::as_str),
2036 Some("atmosphere.density_altitude"),
2037 "{commands:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
2043 let out = call(json!({
2044 "api_version": 1,
2045 "command": "atmosphere.density_altitude",
2046 "request": {"atmosphere": {
2047 "altitude_m": 0.0,
2048 "temperature_k": 288.15,
2049 "pressure_pa": 101325.0,
2050 "relative_humidity": 0.5,
2051 }},
2052 }));
2053 assert_eq!(out["ok"], true, "{out}");
2054 assert_eq!(out["command"], "atmosphere.density_altitude");
2055 let result = &out["result"];
2056 let faa = result["density_altitude"]["faa_rule"]["m"]
2057 .as_f64()
2058 .unwrap();
2059 let matched = result["density_altitude"]["density_matched"]["m"]
2060 .as_f64()
2061 .unwrap();
2062 assert!(faa.abs() < 1e-9, "{result}");
2063 assert!((matched - 33.10).abs() < 0.2, "{result}");
2064 let ft = result["density_altitude"]["density_matched"]["ft"]
2065 .as_f64()
2066 .unwrap();
2067 assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
2068 assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
2069 assert_eq!(result["assumptions"], json!([]), "{result}");
2071 assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
2072 }
2073
2074 #[test]
2075 fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
2076 let out = call(json!({
2077 "api_version": 1,
2078 "command": "atmosphere.density_altitude",
2079 "request": {"atmosphere": {}},
2080 }));
2081 assert_eq!(out["ok"], true, "{out}");
2082 let assumptions = out["result"]["assumptions"].as_array().unwrap();
2083 assert!(!assumptions.is_empty(), "{out}");
2084 for notice in assumptions {
2085 assert!(
2086 notice["path"]
2087 .as_str()
2088 .unwrap_or_default()
2089 .starts_with("$.atmosphere."),
2090 "{notice}"
2091 );
2092 }
2093 }
2094
2095 #[test]
2096 fn density_altitude_without_payload_is_invalid_request() {
2097 let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
2098 assert_eq!(out["ok"], false);
2099 assert_eq!(out["error"]["code"], "invalid_request");
2100 }
2101
2102 #[test]
2103 fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
2104 let out = call(json!({
2108 "api_version": 1,
2109 "command": "atmosphere.density_altitude",
2110 "request": {"atmosphere": {"temperature_c": 30.0}},
2111 }));
2112 assert_eq!(out["ok"], false, "{out}");
2113 assert_eq!(out["error"]["code"], "command_failed");
2114 assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
2115 assert_eq!(
2116 out["error"]["details"]["error"]["path"],
2117 "$.atmosphere.temperature_c"
2118 );
2119 }
2120
2121 #[test]
2122 fn density_altitude_refuses_celsius_sent_as_kelvin() {
2123 let out = call(json!({
2124 "api_version": 1,
2125 "command": "atmosphere.density_altitude",
2126 "request": {"atmosphere": {"temperature_k": 15.0}},
2127 }));
2128 assert_eq!(out["ok"], false, "{out}");
2129 assert_eq!(out["error"]["code"], "command_failed");
2130 assert_eq!(
2131 out["error"]["details"]["error"]["path"],
2132 "$.atmosphere.temperature_k"
2133 );
2134 }
2135
2136 #[test]
2137 fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
2138 let out = call(json!({
2139 "api_version": 1,
2140 "command": "atmosphere.density_altitude",
2141 "request": {"atmosphere": {"relative_humidity": 50.0}},
2142 }));
2143 assert_eq!(out["ok"], false, "{out}");
2144 assert_eq!(out["error"]["code"], "command_failed");
2145 assert!(
2146 out["error"]["details"]
2147 .to_string()
2148 .contains("$.atmosphere.relative_humidity"),
2149 "{out}"
2150 );
2151 }
2152
2153 #[test]
2154 fn invalid_json_is_an_envelope_not_a_panic() {
2155 let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
2156 assert_eq!(out["ok"], false);
2157 assert_eq!(out["error"]["code"], "invalid_json");
2158 }
2159
2160 #[test]
2161 fn unknown_envelope_field_is_rejected() {
2162 let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
2163 assert_eq!(out["ok"], false);
2164 assert_eq!(out["error"]["code"], "invalid_json");
2165 }
2166
2167 #[test]
2168 fn unknown_command_lists_supported_ones() {
2169 let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
2172 assert_eq!(out["error"]["code"], "unknown_command");
2173 assert!(out["error"]["message"]
2174 .as_str()
2175 .unwrap()
2176 .contains("meta.capabilities"));
2177 }
2178
2179 #[test]
2180 fn wrong_api_version_is_rejected() {
2181 let out = call(json!({"api_version": 99, "command": "meta.version"}));
2182 assert_eq!(out["error"]["code"], "unsupported_api_version");
2183 }
2184
2185 #[test]
2186 fn oversize_request_is_a_resource_limit() {
2187 let big = format!(
2188 r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
2189 "x".repeat(MAX_REQUEST_BYTES)
2190 );
2191 let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
2192 assert_eq!(out["error"]["code"], "resource_limit");
2193 }
2194
2195 #[test]
2196 fn solve_without_payload_is_invalid_request() {
2197 let out = call(json!({"api_version": 1, "command": "solve"}));
2198 assert_eq!(out["error"]["code"], "invalid_request");
2199 }
2200
2201 #[test]
2202 fn profile_commands_without_payload_are_invalid_requests() {
2203 for command in ["profile.validate", "profile.normalize"] {
2204 let out = call(json!({"api_version": 1, "command": command}));
2205 assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
2206 assert!(
2207 out["error"]["message"]
2208 .as_str()
2209 .unwrap()
2210 .contains("ProfileData"),
2211 "{command}: {out}"
2212 );
2213 }
2214 }
2215
2216 #[test]
2217 fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
2218 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2219 let commands: Vec<String> =
2220 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2221 assert!(commands.contains(&"profile.validate".to_string()));
2222 assert!(commands.contains(&"profile.normalize".to_string()));
2223 assert_eq!(
2224 commands.contains(&"profile.import_a7p".to_string()),
2225 cfg!(feature = "profile-import"),
2226 "profile.import_a7p must be listed exactly when compiled in"
2227 );
2228 assert_eq!(
2229 commands.contains(&"profile.export_a7p".to_string()),
2230 cfg!(feature = "profile-export"),
2231 "profile.export_a7p must be listed exactly when compiled in"
2232 );
2233 assert_eq!(
2234 commands.contains(&"bc5d.info".to_string()),
2235 cfg!(not(target_arch = "wasm32")),
2236 "bc5d.info must be listed exactly when the build has filesystem access"
2237 );
2238 }
2239
2240 #[cfg(not(target_arch = "wasm32"))]
2241 #[test]
2242 fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
2243 let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
2244 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2245
2246 let out = call(json!({
2247 "api_version": 1,
2248 "command": "bc5d.info",
2249 "request": {"path": "/nonexistent/bc5d_308.bin"}
2250 }));
2251 assert_eq!(out["error"]["code"], "command_failed", "{out}");
2252 assert!(
2253 out["error"]["message"]
2254 .as_str()
2255 .unwrap()
2256 .contains("not a usable BC5D table"),
2257 "{out}"
2258 );
2259 }
2260
2261 #[cfg(feature = "profile-import")]
2262 #[test]
2263 fn base64_decoder_round_trips_and_rejects_garbage() {
2264 for (text, bytes) in [
2266 ("", &b""[..]),
2267 ("Zg==", b"f"),
2268 ("Zm8=", b"fo"),
2269 ("Zm9v", b"foo"),
2270 ("Zm9vYg==", b"foob"),
2271 ("Zm9vYmE=", b"fooba"),
2272 ("Zm9vYmFy", b"foobar"),
2273 ] {
2274 assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
2275 }
2276 assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
2277 assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
2278 assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
2279 assert!(
2280 decode_base64("Zm9v!").is_err(),
2281 "non-alphabet byte is rejected"
2282 );
2283 }
2284
2285 #[test]
2289 fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2290 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2291 let commands: Vec<String> =
2292 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2293 assert_eq!(
2294 commands.contains(&"card.pdf".to_string()),
2295 cfg!(feature = "pdf"),
2296 "card.pdf must be listed exactly when compiled in: {out}"
2297 );
2298 let features: Vec<String> =
2299 serde_json::from_value(out["result"]["features"].clone()).unwrap();
2300 assert_eq!(
2301 features.contains(&"pdf".to_string()),
2302 cfg!(feature = "pdf"),
2303 "the command list and the feature list must agree: {out}"
2304 );
2305 }
2306
2307 #[cfg(not(feature = "pdf"))]
2308 #[test]
2309 fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2310 let out = call(json!({
2311 "api_version": 1,
2312 "command": "card.pdf",
2313 "request": {
2314 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2315 "mass": 175.0, "diameter": 0.308,
2316 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2317 }
2318 }));
2319 assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2320 }
2321
2322 #[test]
2326 fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2327 let out = call(json!({
2328 "api_version": 1,
2329 "command": "card.range_table",
2330 "request": {
2331 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2332 "mass": 175.0, "diameter": 0.308,
2333 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2334 "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2335 }
2336 }));
2337 assert_eq!(out["ok"], true, "{out}");
2338 assert_eq!(out["result"]["kind"], "range_table", "{out}");
2339 }
2340
2341 #[cfg(feature = "pdf")]
2342 #[test]
2343 fn card_pdf_without_payload_is_invalid_request() {
2344 let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2345 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2346 assert!(
2347 out["error"]["message"]
2348 .as_str()
2349 .unwrap()
2350 .contains("card v1 document"),
2351 "{out}"
2352 );
2353 }
2354
2355 #[cfg(feature = "pdf")]
2359 #[test]
2360 fn pdf_output_cap_refuses_only_over_the_limit() {
2361 assert!(pdf_over_cap_error(0, 0, 0).is_none());
2362 assert!(
2363 pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2364 "a document exactly at the cap fits"
2365 );
2366 let envelope: Value =
2367 serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2368 .unwrap();
2369 assert_eq!(envelope["ok"], false);
2370 assert_eq!(envelope["error"]["code"], "resource_limit");
2371 let message = envelope["error"]["message"].as_str().unwrap();
2372 assert!(message.contains("dope card"), "{envelope}");
2373 assert!(message.contains("6 rows"), "{envelope}");
2375 assert!(message.contains("1 pages"), "{envelope}");
2376 for absent in ["coarsen", "shorten"] {
2377 assert!(!message.contains(absent), "{envelope}");
2378 }
2379 }
2380
2381 #[cfg(feature = "pdf")]
2382 #[test]
2383 fn base64_encoder_matches_the_rfc_4648_vectors() {
2384 for (bytes, text) in [
2385 (&b""[..], ""),
2386 (b"f", "Zg=="),
2387 (b"fo", "Zm8="),
2388 (b"foo", "Zm9v"),
2389 (b"foob", "Zm9vYg=="),
2390 (b"fooba", "Zm9vYmE="),
2391 (b"foobar", "Zm9vYmFy"),
2392 ] {
2393 assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2394 }
2395 assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2398 assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2399 assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2400 }
2401
2402 #[cfg(all(feature = "pdf", feature = "profile-import"))]
2405 #[test]
2406 fn base64_encode_decode_round_trips_arbitrary_bytes() {
2407 for len in 0..=32usize {
2408 let bytes: Vec<u8> = (0..len)
2409 .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2410 .collect();
2411 let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2412 assert_eq!(decoded, bytes, "len {len}");
2413 }
2414 }
2415
2416 #[test]
2417 fn solve_with_bad_schema_carries_typed_details() {
2418 let out = call(json!({
2419 "api_version": 1,
2420 "command": "solve",
2421 "request": {"schema_version": 1, "unknown_field": true}
2422 }));
2423 assert_eq!(out["error"]["code"], "command_failed");
2424 assert_eq!(out["error"]["details"]["status"], "error");
2426 }
2427
2428 fn hash_reticle() -> Value {
2435 json!({
2436 "name": "test cross",
2437 "focal_plane": "ffp",
2438 "reference_magnification": 1.0,
2439 "marks": [
2440 {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2441 {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2442 {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2443 {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2444 ],
2445 })
2446 }
2447
2448 #[test]
2449 fn capabilities_lists_the_reticle_family() {
2450 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2453 let commands: Vec<String> =
2454 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2455 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2456 assert!(
2457 commands.contains(&command.to_string()),
2458 "{command} dispatches but meta.capabilities does not list it"
2459 );
2460 }
2461 }
2462
2463 #[test]
2464 fn hold_reports_the_nearest_mark_and_its_label() {
2465 let out = call(json!({
2466 "api_version": 1,
2467 "command": "reticle.hold",
2468 "request": {
2469 "reticle": hash_reticle(),
2470 "drop_mil": 2.1,
2471 "wind_mil": 0.0,
2472 "magnification": 10.0,
2473 },
2474 }));
2475 assert_eq!(out["ok"], true, "{out}");
2476 assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2479 assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2480 assert_eq!(out["result"]["hold"]["off_reticle"], false);
2481 assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2482 assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2483 assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2484 }
2485
2486 #[test]
2487 fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2488 let mut sfp = hash_reticle();
2492 sfp["focal_plane"] = json!("sfp");
2493 sfp["reference_magnification"] = json!(10.0);
2494
2495 let at_reference = call(json!({
2496 "api_version": 1, "command": "reticle.hold",
2497 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2498 }));
2499 assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2500 assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2501 assert_eq!(
2502 at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2503 2.0
2504 );
2505
2506 let zoomed_out = call(json!({
2509 "api_version": 1, "command": "reticle.hold",
2510 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2511 }));
2512 assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2513 assert_eq!(
2514 zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2515 "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2516 );
2517 assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2518 assert_eq!(
2520 zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2521 0.0
2522 );
2523
2524 let on_the_scaled_hash = call(json!({
2532 "api_version": 1, "command": "reticle.hold",
2533 "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2534 }));
2535 assert_eq!(
2536 on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2537 "as authored, the mark is at 2 mil"
2538 );
2539 assert_eq!(
2540 on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2541 "at 5x it subtends 4 mil, which is what the hold is measured against"
2542 );
2543 assert_eq!(
2544 on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2545 "the hold lands exactly on it"
2546 );
2547 }
2548
2549 #[test]
2550 fn a_hold_off_the_marked_part_says_so() {
2551 let out = call(json!({
2554 "api_version": 1, "command": "reticle.hold",
2555 "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2556 }));
2557 assert_eq!(out["ok"], true);
2558 assert_eq!(out["result"]["hold"]["off_reticle"], true);
2559 }
2560
2561 #[test]
2562 fn describe_builds_from_a_generator_and_reports_the_plane() {
2563 let out = call(json!({
2564 "api_version": 1, "command": "reticle.describe",
2565 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2566 }));
2567 assert_eq!(out["ok"], true, "{out}");
2568 assert_eq!(out["result"]["focal_plane"], "FFP");
2569 assert_eq!(out["result"]["magnification_dependent"], false);
2570 assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2571 assert!(out["result"]["scaled"].is_null());
2573 }
2574
2575 #[test]
2576 fn a_generated_reticle_can_be_made_sfp() {
2577 let out = call(json!({
2581 "api_version": 1, "command": "reticle.describe",
2582 "request": {
2583 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2584 "focal_plane": "sfp",
2585 "reference_magnification": 12.0,
2586 "magnification": 6.0,
2587 },
2588 }));
2589 assert_eq!(out["ok"], true, "{out}");
2590 assert_eq!(out["result"]["focal_plane"], "SFP");
2591 assert_eq!(out["result"]["magnification_dependent"], true);
2592 assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2593 }
2594
2595 #[test]
2596 fn a_bdc_ladder_carries_its_range_labels() {
2597 let out = call(json!({
2598 "api_version": 1, "command": "reticle.describe",
2599 "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2600 }));
2601 assert_eq!(out["ok"], true, "{out}");
2602 let marks = out["result"]["reticle"]["marks"]
2603 .as_array()
2604 .unwrap()
2605 .clone();
2606 let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2607 assert_eq!(labels, vec!["300 m", "400 m"]);
2609 }
2610
2611 #[test]
2612 fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2613 let out = call(json!({
2615 "api_version": 1, "command": "reticle.hold",
2616 "request": {
2617 "reticle": hash_reticle(),
2618 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2619 "drop_mil": 1.0,
2620 "magnification": 10.0,
2621 },
2622 }));
2623 assert_eq!(out["ok"], false);
2624 assert_eq!(out["error"]["code"], "invalid_request");
2625 assert!(out["error"]["message"]
2626 .as_str()
2627 .unwrap()
2628 .contains("exactly one"));
2629 }
2630
2631 #[test]
2632 fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2633 let out = call(json!({
2634 "api_version": 1, "command": "reticle.hold",
2635 "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2636 }));
2637 assert_eq!(out["ok"], true, "{out}");
2638 assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2640 assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2641 assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2642 }
2643
2644 #[test]
2645 fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2646 let out = call(json!({
2649 "api_version": 1, "command": "reticle.hold",
2650 "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2651 }));
2652 assert_eq!(out["ok"], false);
2653 assert_eq!(out["error"]["code"], "invalid_request");
2654 assert!(out["error"]["message"]
2655 .as_str()
2656 .unwrap()
2657 .contains("reticle.catalog"));
2658 }
2659
2660 #[test]
2661 fn naming_a_reticle_three_ways_is_refused_too() {
2662 let out = call(json!({
2664 "api_version": 1, "command": "reticle.describe",
2665 "request": {
2666 "catalog": "mil-dot",
2667 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2668 },
2669 }));
2670 assert_eq!(out["ok"], false);
2671 assert_eq!(out["error"]["code"], "invalid_request");
2672 }
2673
2674 #[test]
2675 fn the_catalog_lists_what_the_other_commands_will_accept() {
2676 let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2679 assert_eq!(out["ok"], true, "{out}");
2680 let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2681 assert!(!reticles.is_empty());
2682 for entry in reticles {
2683 let id = entry["id"].as_str().unwrap();
2684 assert!(
2686 !entry["source"].as_str().unwrap().is_empty(),
2687 "{id} has no source"
2688 );
2689 assert!(
2690 !entry["notes"].as_str().unwrap().is_empty(),
2691 "{id} has no notes"
2692 );
2693 assert!(
2694 entry["mark_count"].as_u64().unwrap() > 0,
2695 "{id} has no marks"
2696 );
2697
2698 let held = call(json!({
2699 "api_version": 1, "command": "reticle.hold",
2700 "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2701 }));
2702 assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2703 }
2704 }
2705
2706 #[test]
2707 fn a_batch_of_holds_comes_back_in_request_order() {
2708 let out = call(json!({
2710 "api_version": 1, "command": "reticle.holds",
2711 "request": {
2712 "catalog": "mil-dot",
2713 "magnification": 10.0,
2714 "holds": [
2715 {"drop_mil": 1.0},
2716 {"drop_mil": 3.0, "wind_mil": 0.0},
2717 {"drop_mil": 2.0},
2718 ],
2719 },
2720 }));
2721 assert_eq!(out["ok"], true, "{out}");
2722 let holds = out["holds"].clone();
2723 assert!(holds.is_null(), "holds live under result, not the envelope");
2724 let holds = out["result"]["holds"].as_array().unwrap().clone();
2725 assert_eq!(holds.len(), 3);
2726 assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2728 assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2729 assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2730 assert_eq!(out["result"]["mark_scale"], 1.0);
2732 assert!(holds[0]["hold"]["mark_scale"].is_null());
2733 }
2734
2735 #[test]
2736 fn a_batch_agrees_with_the_single_hold_command() {
2737 let single = call(json!({
2740 "api_version": 1, "command": "reticle.hold",
2741 "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2742 }));
2743 let batch = call(json!({
2744 "api_version": 1, "command": "reticle.holds",
2745 "request": {
2746 "catalog": "mil-dot", "magnification": 10.0,
2747 "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2748 },
2749 }));
2750 assert_eq!(single["ok"], true);
2751 assert_eq!(batch["ok"], true);
2752 assert_eq!(
2753 single["result"]["nearest_mark"],
2754 batch["result"]["holds"][0]["nearest_mark"]
2755 );
2756 assert_eq!(
2757 single["result"]["hold"]["off_reticle"],
2758 batch["result"]["holds"][0]["hold"]["off_reticle"]
2759 );
2760 assert_eq!(
2761 single["result"]["hold"]["nearest_mark_distance_mil"],
2762 batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2763 );
2764 }
2765
2766 #[test]
2767 fn a_malformed_hold_entry_refuses_the_whole_batch() {
2768 for bad in [
2781 json!(null),
2782 json!("3.0"),
2783 json!({"drop_mil": 1.0, "nonsense": 2}),
2784 ] {
2785 let out = call(json!({
2786 "api_version": 1, "command": "reticle.holds",
2787 "request": {
2788 "catalog": "mil-dot", "magnification": 10.0,
2789 "holds": [{"drop_mil": 1.0}, bad],
2790 },
2791 }));
2792 assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2793 assert_eq!(out["error"]["code"], "invalid_request");
2794 }
2795 }
2796
2797 #[test]
2798 fn a_bad_magnification_fails_the_batch_before_any_row() {
2799 let out = call(json!({
2801 "api_version": 1, "command": "reticle.holds",
2802 "request": {
2803 "catalog": "mil-dot", "magnification": 0.0,
2804 "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2805 },
2806 }));
2807 assert_eq!(out["ok"], false);
2808 assert_eq!(
2809 out["error"]["details"]["reason"],
2810 "non_positive_magnification"
2811 );
2812 }
2813
2814 #[test]
2815 fn an_empty_batch_is_an_empty_list_not_an_error() {
2816 let out = call(json!({
2818 "api_version": 1, "command": "reticle.holds",
2819 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2820 }));
2821 assert_eq!(out["ok"], true, "{out}");
2822 assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2823 }
2824
2825 #[test]
2826 fn too_many_holds_is_a_resource_limit() {
2827 let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2828 .map(|i| json!({"drop_mil": (i % 5) as f64}))
2829 .collect();
2830 let out = call(json!({
2831 "api_version": 1, "command": "reticle.holds",
2832 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2833 }));
2834 assert_eq!(out["ok"], false);
2835 assert_eq!(out["error"]["code"], "resource_limit");
2836 }
2837
2838 #[test]
2839 fn capabilities_lists_the_catalog_command() {
2840 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2841 let commands: Vec<String> =
2842 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2843 assert!(commands.contains(&"reticle.catalog".to_string()));
2844 assert!(commands.contains(&"reticle.holds".to_string()));
2845 }
2846
2847 #[test]
2848 fn naming_no_reticle_at_all_is_refused() {
2849 let out = call(json!({
2850 "api_version": 1, "command": "reticle.hold",
2851 "request": {"drop_mil": 1.0, "magnification": 10.0},
2852 }));
2853 assert_eq!(out["ok"], false);
2854 assert_eq!(out["error"]["code"], "invalid_request");
2855 }
2856
2857 #[test]
2858 fn plane_overrides_beside_a_full_reticle_are_refused() {
2859 let out = call(json!({
2862 "api_version": 1, "command": "reticle.describe",
2863 "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2864 }));
2865 assert_eq!(out["ok"], false);
2866 assert_eq!(out["error"]["code"], "invalid_request");
2867 }
2868
2869 #[test]
2870 fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2871 let out = call(json!({
2875 "api_version": 1, "command": "reticle.hold",
2876 "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2877 }));
2878 assert_eq!(out["ok"], false);
2879 assert_eq!(out["error"]["code"], "command_failed");
2880 assert_eq!(
2881 out["error"]["details"]["reason"],
2882 "non_positive_magnification"
2883 );
2884 assert_eq!(out["error"]["details"]["magnification"], 0.0);
2885 assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2886 }
2887
2888 #[test]
2889 fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2890 let mut empty = hash_reticle();
2893 empty["marks"] = json!([]);
2894 let out = call(json!({
2895 "api_version": 1, "command": "reticle.describe",
2896 "request": {"reticle": empty},
2897 }));
2898 assert_eq!(out["ok"], false);
2899 assert_eq!(out["error"]["details"]["reason"], "no_marks");
2900 }
2901
2902 #[test]
2903 fn an_unreadable_generator_parameter_names_the_parameter() {
2904 let out = call(json!({
2905 "api_version": 1, "command": "reticle.describe",
2906 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2907 }));
2908 assert_eq!(out["ok"], false);
2909 assert_eq!(
2910 out["error"]["details"]["reason"],
2911 "invalid_generator_parameter"
2912 );
2913 assert_eq!(out["error"]["details"]["parameter"], "spacing");
2914 }
2915
2916 #[test]
2917 fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2918 let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2924 {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2925 {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2926 {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2927
2928 let out = call(json!({
2929 "api_version": 1, "command": "reticle.import",
2930 "request": {"format": "ventum", "document": document},
2931 }));
2932 assert_eq!(out["ok"], true, "{out}");
2933 assert_eq!(out["result"]["format"], "ventum");
2934 assert_eq!(out["result"]["mark_count"], 78);
2935 assert_eq!(out["result"]["reticle"]["name"], "MBR");
2936 assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2937 assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2939 assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2940
2941 let held = call(json!({
2943 "api_version": 1, "command": "reticle.hold",
2944 "request": {
2945 "reticle": out["result"]["reticle"],
2946 "drop_mil": 8.0,
2947 "wind_mil": 2.0,
2948 "magnification": 10.0,
2949 },
2950 }));
2951 assert_eq!(held["ok"], true, "{held}");
2952 assert_eq!(held["result"]["hold"]["off_reticle"], false);
2953 assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2954 assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2955 assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2956 }
2957
2958 #[test]
2959 fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2960 let out = call(json!({
2961 "api_version": 1, "command": "reticle.import",
2962 "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2963 }));
2964 assert_eq!(out["ok"], false);
2965 assert_eq!(out["error"]["code"], "command_failed");
2966 }
2967
2968 #[test]
2969 fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2970 let out = call(json!({
2973 "api_version": 1, "command": "reticle.import",
2974 "request": {
2975 "format": "ventum",
2976 "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2977 },
2978 }));
2979 assert_eq!(out["ok"], false);
2980 assert_eq!(out["error"]["code"], "resource_limit");
2981 assert!(out["error"]["message"]
2982 .as_str()
2983 .unwrap()
2984 .contains("reticle document"));
2985 }
2986
2987 #[test]
2988 fn every_reticle_command_rejects_an_unknown_request_field() {
2989 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2990 let out = call(json!({
2991 "api_version": 1,
2992 "command": command,
2993 "request": {"nonsense": 1},
2994 }));
2995 assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2996 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2997 }
2998 }
2999
3000 #[test]
3001 fn every_reticle_command_rejects_a_missing_payload() {
3002 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
3003 let out = call(json!({"api_version": 1, "command": command}));
3004 assert_eq!(out["ok"], false, "{command} accepted a null payload");
3005 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
3006 }
3007 }
3008
3009 #[cfg(all(feature = "profile-export", feature = "profile-import"))]
3014 #[test]
3015 fn export_a7p_produces_a_file_import_a7p_reads_back() {
3016 let profile = json!({
3017 "name": "bridge-export",
3018 "velocity": 792.0,
3019 "bc": 0.381,
3020 "mass": 19.439673,
3021 "diameter": 8.5852,
3022 "drag_model": "G7",
3023 "twist_rate": 254.0,
3024 "sight_height": 90.0,
3025 "zero_distance": 100.0,
3026 "units": "metric",
3027 "temperature": 15.0,
3028 "pressure": 1000.0,
3029 "humidity": 50.0,
3030 "altitude": 0.0,
3031 "bullet_name": "300GR OTM",
3032 "twist_right": false,
3033 "bullet_length": 45.72,
3034 "elevation_cf": 0.97,
3035 "dsf_points": [{"mach": 0.9, "dsf": 1.04}]
3036 });
3037
3038 let out = call(json!({
3039 "api_version": 1, "command": "profile.export_a7p", "request": profile
3040 }));
3041 assert_eq!(out["ok"], true, "{out}");
3042 let result = &out["result"];
3043 assert!(result["byte_length"].as_u64().unwrap() > 32);
3044
3045 let dropped: Vec<String> =
3048 serde_json::from_value(result["dropped_fields"].clone()).unwrap();
3049 assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
3050 assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
3051 assert!(!dropped.contains(&"velocity".to_string()), "{out}");
3052 let not_carried = result["not_carried"].as_array().unwrap();
3055 let reticle = not_carried
3056 .iter()
3057 .find(|e| e["field"] == "reticle")
3058 .expect("reticle is listed even though this profile has none");
3059 assert_eq!(reticle["populated"], false, "{out}");
3060 assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
3061
3062 let back = call(json!({
3063 "api_version": 1,
3064 "command": "profile.import_a7p",
3065 "request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
3066 }));
3067 assert_eq!(back["ok"], true, "{back}");
3070 let reimported = &back["result"]["profile"];
3071 assert_eq!(reimported["name"], "bridge-export", "{back}");
3072 assert_eq!(reimported["drag_model"], "G7", "{back}");
3073 assert_eq!(reimported["twist_right"], false, "{back}");
3074 }
3075
3076 #[cfg(feature = "profile-export")]
3079 #[test]
3080 fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
3081 let out = call(json!({
3082 "api_version": 1,
3083 "command": "profile.export_a7p",
3084 "request": {
3089 "name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
3090 "diameter": 8.5852, "drag_model": "G5", "units": "metric",
3091 "bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
3092 "temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
3093 }
3094 }));
3095 assert_eq!(out["error"]["code"], "command_failed", "{out}");
3096 assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
3097 assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
3098 }
3099
3100 #[cfg(feature = "profile-export")]
3101 #[test]
3102 fn export_a7p_without_a_payload_is_an_invalid_request() {
3103 let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
3104 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
3105 assert!(
3106 out["error"]["message"]
3107 .as_str()
3108 .unwrap()
3109 .contains("ProfileData"),
3110 "{out}"
3111 );
3112 }
3113}