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")]
685fn run_card_pdf(inner: &Value) -> String {
686 use crate::card_service::CardServiceError;
687
688 if inner.is_null() {
689 return error(
690 BridgeErrorCode::InvalidRequest,
691 "'card.pdf' requires a request payload (card v1 document)",
692 None,
693 );
694 }
695 let mut payload = inner.clone();
696 let stored_value = payload
697 .as_object_mut()
698 .and_then(|object| object.remove(STORED_CARD_KEY))
699 .filter(|value| !value.is_null());
700 let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
701 Ok(request) => request,
702 Err(err) => {
703 return error(
704 BridgeErrorCode::InvalidRequest,
705 format!("card.pdf request rejected: {err}"),
706 None,
707 )
708 }
709 };
710 let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
711 Some(value) => match serde_json::from_value(value) {
712 Ok(stored) => Some(stored),
713 Err(err) => {
714 return error(
715 BridgeErrorCode::InvalidRequest,
716 format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
717 None,
718 )
719 }
720 },
721 None => None,
722 };
723
724 let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
725 Ok(card) => card,
726 Err(err @ CardServiceError::TooLarge(_)) => {
729 return error(
730 BridgeErrorCode::ResourceLimit,
731 format!("card.pdf refused: {err}"),
732 None,
733 )
734 }
735 Err(err) => {
736 return error(
737 BridgeErrorCode::CommandFailed,
738 format!("card.pdf failed: {err}"),
739 None,
740 )
741 }
742 };
743 let byte_length = card.pdf_bytes.len();
744 if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
745 return envelope;
746 }
747 let mut response = json!({
748 "pdf_base64": encode_base64(&card.pdf_bytes),
749 "byte_length": byte_length,
750 "page_count": card.page_count,
751 "row_count": card.row_count,
752 "kind": crate::card_service::PDF_CARD_KIND,
753 "source": card.source.as_str(),
754 "unprintable_title_chars": card.unprintable_title_chars,
755 });
756 if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
762 object.insert(
763 "truncation".to_string(),
764 json!({
765 "requested_end": truncation.requested_end,
766 "last_row": truncation.last_row,
767 "reach": truncation.reach,
768 }),
769 );
770 }
771 success("card.pdf", response)
772}
773
774#[cfg(any(feature = "pdf", feature = "profile-export"))]
785fn encode_base64(bytes: &[u8]) -> String {
786 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
787 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
788 for chunk in bytes.chunks(3) {
789 let triple = (u32::from(chunk[0]) << 16)
790 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
791 | u32::from(chunk.get(2).copied().unwrap_or(0));
792 out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
793 out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
794 out.push(if chunk.len() > 1 {
796 char::from(ALPHABET[(triple >> 6) as usize & 63])
797 } else {
798 '='
799 });
800 out.push(if chunk.len() > 2 {
801 char::from(ALPHABET[triple as usize & 63])
802 } else {
803 '='
804 });
805 }
806 out
807}
808
809fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
811 let details = serde_json::to_value(typed).ok();
812 error(BridgeErrorCode::CommandFailed, message, details)
813}
814
815fn decode_profile_document(
820 inner: &Value,
821 command: &'static str,
822) -> Result<crate::profile::ProfileData, String> {
823 if inner.is_null() {
824 return Err(error(
825 BridgeErrorCode::InvalidRequest,
826 format!("'{command}' requires a request payload (a ProfileData JSON document)"),
827 None,
828 ));
829 }
830 serde_json::from_value(inner.clone()).map_err(|err| {
831 error(
832 BridgeErrorCode::InvalidRequest,
833 format!("{command} request is not a ProfileData document: {err}"),
834 None,
835 )
836 })
837}
838
839fn run_profile_validate(inner: &Value) -> String {
846 let profile = match decode_profile_document(inner, "profile.validate") {
847 Ok(profile) => profile,
848 Err(envelope) => return envelope,
849 };
850 let warnings = profile.validation_warnings();
851 match serde_json::to_value(&profile) {
852 Ok(normalized) => success(
853 "profile.validate",
854 json!({
855 "valid": warnings.is_empty(),
856 "warnings": warnings,
857 "normalized": normalized,
858 }),
859 ),
860 Err(err) => error(
861 BridgeErrorCode::InternalError,
862 format!("failed to serialize normalized profile: {err}"),
863 None,
864 ),
865 }
866}
867
868fn run_profile_normalize(inner: &Value) -> String {
874 let profile = match decode_profile_document(inner, "profile.normalize") {
875 Ok(profile) => profile,
876 Err(envelope) => return envelope,
877 };
878 match serde_json::to_value(&profile) {
879 Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
880 Err(err) => error(
881 BridgeErrorCode::InternalError,
882 format!("failed to serialize normalized profile: {err}"),
883 None,
884 ),
885 }
886}
887
888#[cfg(not(target_arch = "wasm32"))]
890#[derive(Debug, Deserialize)]
891#[serde(deny_unknown_fields)]
892struct Bc5dInfoRequest {
893 path: String,
894}
895
896#[cfg(not(target_arch = "wasm32"))]
916fn run_bc5d_info(inner: &Value) -> String {
917 if inner.is_null() {
918 return error(
919 BridgeErrorCode::InvalidRequest,
920 "'bc5d.info' requires a request payload ({\"path\": ...})",
921 None,
922 );
923 }
924 let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
925 Ok(request) => request,
926 Err(err) => {
927 return error(
928 BridgeErrorCode::InvalidRequest,
929 format!("bc5d.info request rejected: {err}"),
930 None,
931 )
932 }
933 };
934
935 let table =
936 match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
937 Ok(table) => table,
938 Err(err) => {
939 return error(
940 BridgeErrorCode::CommandFailed,
941 format!("bc5d.info: not a usable BC5D table: {err}"),
942 None,
943 )
944 }
945 };
946
947 let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
948 let (weight_lo, weight_hi) = table.weight_range();
949 let (vel_lo, vel_hi) = table.velocity_range();
950 success(
951 "bc5d.info",
952 json!({
953 "valid": true,
956 "crc_ok": true,
957 "format_version": table.version(),
958 "caliber": table.caliber(),
959 "caliber_key": table.caliber_key(),
961 "api_version": table.api_version(),
962 "generated_timestamp": table.timestamp(),
963 "bins": {
965 "weight": weight,
966 "bc": bc,
967 "muzzle_velocity": muzzle_vel,
968 "current_velocity": current_vel,
969 "drag_types": drag_types,
970 },
971 "total_cells": table.total_cells(),
972 "weight_range_grains": [weight_lo, weight_hi],
973 "velocity_range_fps": [vel_lo, vel_hi],
974 }),
975 )
976}
977
978#[cfg(feature = "profile-import")]
982pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
983
984#[cfg(feature = "profile-import")]
990#[derive(Debug, Deserialize)]
991#[serde(deny_unknown_fields)]
992struct ProfileImportA7pRequest {
993 a7p_base64: String,
994 #[serde(default)]
995 zero_click: Option<String>,
996 #[serde(default)]
997 strict: bool,
998}
999
1000#[cfg(feature = "profile-import")]
1010fn run_profile_import_a7p(inner: &Value) -> String {
1011 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
1012
1013 if inner.is_null() {
1014 return error(
1015 BridgeErrorCode::InvalidRequest,
1016 "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
1017 None,
1018 );
1019 }
1020 let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
1021 Ok(request) => request,
1022 Err(err) => {
1023 return error(
1024 BridgeErrorCode::InvalidRequest,
1025 format!("profile.import_a7p request rejected: {err}"),
1026 None,
1027 )
1028 }
1029 };
1030
1031 let zero_click = match request.zero_click.as_deref() {
1032 Some(raw) => match crate::adjustment::parse_click_value(raw) {
1033 Ok(click) => Some(click),
1034 Err(err) => {
1035 return error(
1036 BridgeErrorCode::InvalidRequest,
1037 format!("profile.import_a7p zero_click: {err}"),
1038 None,
1039 )
1040 }
1041 },
1042 None => None,
1043 };
1044
1045 let bytes = match decode_base64(&request.a7p_base64) {
1046 Ok(bytes) => bytes,
1047 Err(err) => {
1048 return error(
1049 BridgeErrorCode::InvalidRequest,
1050 format!("profile.import_a7p a7p_base64: {err}"),
1051 None,
1052 )
1053 }
1054 };
1055 if bytes.len() > MAX_A7P_DECODED_BYTES {
1056 return error(
1057 BridgeErrorCode::ResourceLimit,
1058 format!(
1059 "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
1060 bytes.len()
1061 ),
1062 None,
1063 );
1064 }
1065
1066 let doc = match parse_a7p(&bytes) {
1067 Ok(doc) => doc,
1068 Err(err) => {
1069 return error(
1070 BridgeErrorCode::CommandFailed,
1071 format!("not a usable .a7p file: {err}"),
1072 None,
1073 )
1074 }
1075 };
1076 if request.strict {
1079 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
1080 return error(
1081 BridgeErrorCode::CommandFailed,
1082 format!(
1083 "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
1084 ),
1085 None,
1086 );
1087 }
1088 }
1089
1090 let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1091 Ok(outcome) => outcome,
1092 Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1093 };
1094 let unknown_fields: Vec<Value> = doc
1095 .unknown_fields
1096 .iter()
1097 .map(|u| json!({ "context": u.context, "number": u.number }))
1098 .collect();
1099 match serde_json::to_value(&outcome.profile) {
1100 Ok(profile) => success(
1101 "profile.import_a7p",
1102 json!({
1103 "profile": profile,
1104 "warnings": outcome.report.warnings,
1105 "mapped": outcome.report.mapped,
1106 "unmapped": outcome.report.unmapped,
1107 "unknown_fields": unknown_fields,
1108 }),
1109 ),
1110 Err(err) => error(
1111 BridgeErrorCode::InternalError,
1112 format!("failed to serialize imported profile: {err}"),
1113 None,
1114 ),
1115 }
1116}
1117
1118#[cfg(feature = "profile-export")]
1140fn run_profile_export_a7p(inner: &Value) -> String {
1141 use crate::profile_export::{export_a7p, A7pExportError, CARRIED_FIELDS};
1142
1143 let profile = match decode_profile_document(inner, "profile.export_a7p") {
1144 Ok(profile) => profile,
1145 Err(envelope) => return envelope,
1146 };
1147 let export = match export_a7p(&profile) {
1148 Ok(export) => export,
1149 Err(err) => {
1150 let details = match &err {
1151 A7pExportError::Units(_) => json!({ "reason": "units" }),
1152 A7pExportError::DragModel(model) => {
1153 json!({ "reason": "drag_model", "drag_model": model })
1154 }
1155 A7pExportError::Field { field, .. } => {
1156 json!({ "reason": "field", "field": field })
1157 }
1158 };
1159 return error(
1160 BridgeErrorCode::CommandFailed,
1161 err.to_string(),
1162 Some(details),
1163 );
1164 }
1165 };
1166 let not_carried = match serde_json::to_value(&export.not_carried) {
1167 Ok(value) => value,
1168 Err(err) => {
1169 return error(
1170 BridgeErrorCode::InternalError,
1171 format!("failed to serialize the not-carried list: {err}"),
1172 None,
1173 )
1174 }
1175 };
1176 success(
1177 "profile.export_a7p",
1178 json!({
1179 "a7p_base64": encode_base64(&export.bytes),
1180 "byte_length": export.bytes.len(),
1181 "carried_fields": CARRIED_FIELDS,
1182 "not_carried": not_carried,
1183 "dropped_fields": export.dropped_fields(),
1184 "warnings": export.warnings,
1185 }),
1186 )
1187}
1188
1189#[cfg(feature = "profile-import")]
1197fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1198 fn sextet(c: u8) -> Result<u32, String> {
1199 match c {
1200 b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1201 b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1202 b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1203 b'+' => Ok(62),
1204 b'/' => Ok(63),
1205 _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1206 }
1207 }
1208 let bytes = input.as_bytes();
1209 let data = match bytes {
1210 [rest @ .., b'=', b'='] => rest,
1211 [rest @ .., b'='] => rest,
1212 _ => bytes,
1213 };
1214 if data.contains(&b'=') {
1215 return Err("'=' is only valid as trailing padding".to_string());
1216 }
1217 if data.len() % 4 == 1 {
1218 return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1219 }
1220 let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1221 let mut acc: u32 = 0;
1222 let mut bits: u32 = 0;
1223 for &c in data {
1224 acc = (acc << 6) | sextet(c)?;
1225 bits += 6;
1226 if bits >= 8 {
1227 bits -= 8;
1228 out.push((acc >> bits) as u8);
1229 }
1230 }
1231 Ok(out)
1232}
1233
1234#[derive(Debug, Deserialize)]
1244#[serde(tag = "kind", rename_all = "snake_case")]
1245enum ReticleGenerator {
1246 MilGrid { spacing_mil: f64, extent_mil: f64 },
1248 Tree {
1250 rows: usize,
1251 row_spacing_mil: f64,
1252 spread_step_mil: f64,
1253 },
1254 Bdc { drops: Vec<(f64, f64)> },
1259}
1260
1261#[derive(Debug, Default, Deserialize)]
1269#[serde(deny_unknown_fields)]
1270struct ReticlePlaneOverride {
1271 #[serde(default)]
1272 focal_plane: Option<crate::reticle::FocalPlane>,
1273 #[serde(default)]
1274 reference_magnification: Option<f64>,
1275}
1276
1277fn resolve_reticle(
1281 command: &'static str,
1282 reticle: Option<crate::reticle::ReticleDescription>,
1283 generator: Option<ReticleGenerator>,
1284 catalog: Option<String>,
1285 plane: &ReticlePlaneOverride,
1286) -> Result<crate::reticle::ReticleDescription, String> {
1287 use crate::reticle::ReticleDescription;
1288
1289 let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1292 .iter()
1293 .filter(|named| **named)
1294 .count();
1295 if named > 1 {
1296 return Err(error(
1297 BridgeErrorCode::InvalidRequest,
1298 format!(
1299 "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1300 supplying more than one is a caller bug rather than a preference"
1301 ),
1302 None,
1303 ));
1304 }
1305
1306 let reticle = match (reticle, catalog) {
1308 (reticle, None) => reticle,
1309 (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1310 Some(Ok(built)) => Some(built),
1311 Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1312 None => {
1315 return Err(error(
1316 BridgeErrorCode::InvalidRequest,
1317 format!(
1318 "{command}: no catalog reticle with id {id:?}; \
1319 'reticle.catalog' lists what this build has"
1320 ),
1321 None,
1322 ))
1323 }
1324 },
1325 };
1326
1327 let mut described = match (reticle, generator) {
1328 (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1329 (None, None) => {
1330 return Err(error(
1331 BridgeErrorCode::InvalidRequest,
1332 format!(
1333 "{command} requires one of 'reticle' (a full description), \
1334 'generator' or 'catalog'"
1335 ),
1336 None,
1337 ))
1338 }
1339 (Some(reticle), None) => {
1340 if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1341 return Err(error(
1342 BridgeErrorCode::InvalidRequest,
1343 format!(
1344 "{command}: 'focal_plane' and 'reference_magnification' override a \
1345 GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1346 ),
1347 None,
1348 ));
1349 }
1350 reticle
1351 }
1352 (None, Some(generator)) => {
1353 let built = match generator {
1354 ReticleGenerator::MilGrid {
1355 spacing_mil,
1356 extent_mil,
1357 } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1358 ReticleGenerator::Tree {
1359 rows,
1360 row_spacing_mil,
1361 spread_step_mil,
1362 } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1363 ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1364 };
1365 match built {
1366 Ok(built) => built,
1367 Err(err) => return Err(reticle_error_envelope(command, &err)),
1368 }
1369 }
1370 };
1371
1372 if let Some(focal_plane) = plane.focal_plane {
1373 described.focal_plane = focal_plane;
1374 }
1375 if let Some(reference_magnification) = plane.reference_magnification {
1376 described.reference_magnification = reference_magnification;
1377 }
1378 Ok(described)
1379}
1380
1381fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1389 use crate::reticle::ReticleError;
1390
1391 let mut details = json!({ "reason": match err {
1392 ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1393 ReticleError::NonPositiveReferenceMagnification { .. } => {
1394 "non_positive_reference_magnification"
1395 }
1396 ReticleError::NoMarks => "no_marks",
1397 ReticleError::TooManyMarks { .. } => "too_many_marks",
1398 ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1399 ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1400 ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1401 ReticleError::InvalidSpec(_) => "invalid_spec",
1402 }});
1403
1404 match err {
1407 ReticleError::NonPositiveMagnification { magnification } => {
1408 details["magnification"] = json!(magnification);
1409 }
1410 ReticleError::NonPositiveReferenceMagnification {
1411 reference_magnification,
1412 } => {
1413 details["reference_magnification"] = json!(reference_magnification);
1414 }
1415 ReticleError::TooManyMarks { count, max } => {
1416 details["count"] = json!(count);
1417 details["max"] = json!(max);
1418 }
1419 ReticleError::NonFiniteMark { index } => {
1420 details["index"] = json!(index);
1421 }
1422 ReticleError::InvalidGeneratorParameter {
1423 parameter,
1424 value,
1425 rule,
1426 } => {
1427 details["parameter"] = json!(parameter);
1428 details["value"] = json!(value);
1429 details["rule"] = json!(rule);
1430 }
1431 ReticleError::NoMarks
1432 | ReticleError::NonFiniteHold { .. }
1433 | ReticleError::InvalidSpec(_) => {}
1434 }
1435 details["command"] = json!(command);
1436
1437 error(
1438 BridgeErrorCode::CommandFailed,
1439 err.to_string(),
1440 Some(details),
1441 )
1442}
1443
1444fn nearest_mark_value(
1452 reticle: &crate::reticle::ReticleDescription,
1453 hold: &crate::reticle::ReticleHold,
1454) -> Value {
1455 match hold
1456 .nearest_mark
1457 .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1458 {
1459 Some((index, mark)) => json!({
1460 "index": index,
1461 "kind": mark.kind.as_str(),
1462 "label": mark.label,
1463 "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1464 "true_angular": {
1465 "down_mil": mark.down_mil * hold.mark_scale,
1466 "right_mil": mark.right_mil * hold.mark_scale,
1467 },
1468 }),
1469 None => Value::Null,
1470 }
1471}
1472
1473fn run_reticle_catalog() -> String {
1484 let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1485 .into_iter()
1486 .map(|entry| {
1487 let mark_count = crate::reticle_catalog::by_id(entry.id)
1491 .and_then(|built| built.ok())
1492 .map(|reticle| reticle.marks.len());
1493 json!({
1494 "id": entry.id,
1495 "display_name": entry.display_name,
1496 "source": entry.source,
1497 "notes": entry.notes,
1498 "mark_count": mark_count,
1499 })
1500 })
1501 .collect();
1502 success("reticle.catalog", json!({ "reticles": reticles }))
1503}
1504
1505#[derive(Debug, Deserialize)]
1506#[serde(deny_unknown_fields)]
1507struct ReticleDescribeRequest {
1508 #[serde(default)]
1509 reticle: Option<crate::reticle::ReticleDescription>,
1510 #[serde(default)]
1511 generator: Option<ReticleGenerator>,
1512 #[serde(default)]
1515 catalog: Option<String>,
1516 #[serde(default)]
1517 focal_plane: Option<crate::reticle::FocalPlane>,
1518 #[serde(default)]
1519 reference_magnification: Option<f64>,
1520 #[serde(default)]
1523 magnification: Option<f64>,
1524}
1525
1526fn run_reticle_describe(inner: &Value) -> String {
1536 if inner.is_null() {
1537 return error(
1538 BridgeErrorCode::InvalidRequest,
1539 "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1540 None,
1541 );
1542 }
1543 let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1544 Ok(request) => request,
1545 Err(err) => {
1546 return error(
1547 BridgeErrorCode::InvalidRequest,
1548 format!("reticle.describe request rejected: {err}"),
1549 None,
1550 )
1551 }
1552 };
1553
1554 let plane = ReticlePlaneOverride {
1555 focal_plane: request.focal_plane,
1556 reference_magnification: request.reference_magnification,
1557 };
1558 let reticle = match resolve_reticle(
1559 "reticle.describe",
1560 request.reticle,
1561 request.generator,
1562 request.catalog,
1563 &plane,
1564 ) {
1565 Ok(reticle) => reticle,
1566 Err(envelope) => return envelope,
1567 };
1568
1569 if let Err(err) = reticle.validate() {
1572 return reticle_error_envelope("reticle.describe", &err);
1573 }
1574
1575 let scaled = match request.magnification {
1576 Some(magnification) => match reticle.scaled_marks(magnification) {
1577 Ok(marks) => json!({
1578 "magnification": magnification,
1579 "mark_scale": reticle.mark_scale(magnification),
1580 "marks": marks
1581 .iter()
1582 .map(|mark| json!({
1583 "down_mil": mark.down_mil,
1584 "right_mil": mark.right_mil,
1585 }))
1586 .collect::<Vec<_>>(),
1587 }),
1588 Err(err) => return reticle_error_envelope("reticle.describe", &err),
1589 },
1590 None => Value::Null,
1591 };
1592
1593 match serde_json::to_value(&reticle) {
1594 Ok(described) => success(
1595 "reticle.describe",
1596 json!({
1597 "reticle": described,
1598 "focal_plane": reticle.focal_plane.label(),
1599 "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1600 "mark_count": reticle.marks.len(),
1601 "scaled": scaled,
1602 }),
1603 ),
1604 Err(err) => error(
1605 BridgeErrorCode::InternalError,
1606 format!("failed to serialize reticle description: {err}"),
1607 None,
1608 ),
1609 }
1610}
1611
1612#[derive(Debug, Deserialize)]
1613#[serde(deny_unknown_fields)]
1614struct ReticleHoldRequest {
1615 #[serde(default)]
1616 reticle: Option<crate::reticle::ReticleDescription>,
1617 #[serde(default)]
1618 generator: Option<ReticleGenerator>,
1619 #[serde(default)]
1622 catalog: Option<String>,
1623 #[serde(default)]
1624 focal_plane: Option<crate::reticle::FocalPlane>,
1625 #[serde(default)]
1626 reference_magnification: Option<f64>,
1627 drop_mil: f64,
1630 #[serde(default)]
1632 wind_mil: f64,
1633 magnification: f64,
1637}
1638
1639fn run_reticle_hold(inner: &Value) -> String {
1655 if inner.is_null() {
1656 return error(
1657 BridgeErrorCode::InvalidRequest,
1658 "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1659 None,
1660 );
1661 }
1662 let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1663 Ok(request) => request,
1664 Err(err) => {
1665 return error(
1666 BridgeErrorCode::InvalidRequest,
1667 format!("reticle.hold request rejected: {err}"),
1668 None,
1669 )
1670 }
1671 };
1672
1673 let plane = ReticlePlaneOverride {
1674 focal_plane: request.focal_plane,
1675 reference_magnification: request.reference_magnification,
1676 };
1677 let reticle = match resolve_reticle(
1678 "reticle.hold",
1679 request.reticle,
1680 request.generator,
1681 request.catalog,
1682 &plane,
1683 ) {
1684 Ok(reticle) => reticle,
1685 Err(envelope) => return envelope,
1686 };
1687
1688 let hold = match crate::reticle::hold_point_in_reticle(
1689 request.drop_mil,
1690 request.wind_mil,
1691 request.magnification,
1692 &reticle,
1693 ) {
1694 Ok(hold) => hold,
1695 Err(err) => return reticle_error_envelope("reticle.hold", &err),
1696 };
1697
1698 let nearest = nearest_mark_value(&reticle, &hold);
1699 match serde_json::to_value(&hold) {
1700 Ok(hold_value) => success(
1701 "reticle.hold",
1702 json!({
1703 "hold": hold_value,
1704 "nearest_mark": nearest,
1705 }),
1706 ),
1707 Err(err) => error(
1708 BridgeErrorCode::InternalError,
1709 format!("failed to serialize reticle hold: {err}"),
1710 None,
1711 ),
1712 }
1713}
1714
1715pub const MAX_RETICLE_HOLDS: usize = 4096;
1720
1721#[derive(Debug, Deserialize)]
1722#[serde(deny_unknown_fields)]
1723struct ReticleHoldsEntry {
1724 drop_mil: f64,
1725 #[serde(default)]
1726 wind_mil: f64,
1727}
1728
1729#[derive(Debug, Deserialize)]
1730#[serde(deny_unknown_fields)]
1731struct ReticleHoldsRequest {
1732 #[serde(default)]
1733 reticle: Option<crate::reticle::ReticleDescription>,
1734 #[serde(default)]
1735 generator: Option<ReticleGenerator>,
1736 #[serde(default)]
1737 catalog: Option<String>,
1738 #[serde(default)]
1739 focal_plane: Option<crate::reticle::FocalPlane>,
1740 #[serde(default)]
1741 reference_magnification: Option<f64>,
1742 magnification: f64,
1743 holds: Vec<ReticleHoldsEntry>,
1746}
1747
1748fn run_reticle_holds(inner: &Value) -> String {
1765 if inner.is_null() {
1766 return error(
1767 BridgeErrorCode::InvalidRequest,
1768 "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1769 None,
1770 );
1771 }
1772 let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1773 Ok(request) => request,
1774 Err(err) => {
1775 return error(
1776 BridgeErrorCode::InvalidRequest,
1777 format!("reticle.holds request rejected: {err}"),
1778 None,
1779 )
1780 }
1781 };
1782
1783 if request.holds.len() > MAX_RETICLE_HOLDS {
1784 return error(
1785 BridgeErrorCode::ResourceLimit,
1786 format!(
1787 "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1788 request.holds.len()
1789 ),
1790 None,
1791 );
1792 }
1793
1794 let plane = ReticlePlaneOverride {
1795 focal_plane: request.focal_plane,
1796 reference_magnification: request.reference_magnification,
1797 };
1798 let reticle = match resolve_reticle(
1799 "reticle.holds",
1800 request.reticle,
1801 request.generator,
1802 request.catalog,
1803 &plane,
1804 ) {
1805 Ok(reticle) => reticle,
1806 Err(envelope) => return envelope,
1807 };
1808
1809 let mut placed = Vec::with_capacity(request.holds.len());
1810 let mut scale = 1.0;
1811 for entry in &request.holds {
1812 let hold = match crate::reticle::hold_point_in_reticle(
1813 entry.drop_mil,
1814 entry.wind_mil,
1815 request.magnification,
1816 &reticle,
1817 ) {
1818 Ok(hold) => hold,
1819 Err(err) => return reticle_error_envelope("reticle.holds", &err),
1826 };
1827 scale = hold.mark_scale;
1828 let nearest = nearest_mark_value(&reticle, &hold);
1829 match serde_json::to_value(&hold) {
1830 Ok(mut value) => {
1831 if let Some(object) = value.as_object_mut() {
1833 object.remove("mark_scale");
1834 }
1835 placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1836 }
1837 Err(err) => {
1838 return error(
1839 BridgeErrorCode::InternalError,
1840 format!("failed to serialize reticle hold: {err}"),
1841 None,
1842 )
1843 }
1844 }
1845 }
1846
1847 success(
1848 "reticle.holds",
1849 json!({ "mark_scale": scale, "holds": placed }),
1850 )
1851}
1852
1853pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1861
1862#[derive(Debug, Deserialize)]
1863#[serde(rename_all = "snake_case")]
1864enum ReticleDocumentFormat {
1865 Ventum,
1867 ReticleXml,
1869}
1870
1871#[derive(Debug, Deserialize)]
1872#[serde(deny_unknown_fields)]
1873struct ReticleImportRequest {
1874 format: ReticleDocumentFormat,
1875 document: String,
1877}
1878
1879fn run_reticle_import(inner: &Value) -> String {
1888 if inner.is_null() {
1889 return error(
1890 BridgeErrorCode::InvalidRequest,
1891 "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1892 None,
1893 );
1894 }
1895 let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1896 Ok(request) => request,
1897 Err(err) => {
1898 return error(
1899 BridgeErrorCode::InvalidRequest,
1900 format!("reticle.import request rejected: {err}"),
1901 None,
1902 )
1903 }
1904 };
1905
1906 if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1907 return error(
1908 BridgeErrorCode::ResourceLimit,
1909 format!(
1910 "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1911 request.document.len()
1912 ),
1913 None,
1914 );
1915 }
1916
1917 let (reticle, report, format) = match request.format {
1918 ReticleDocumentFormat::Ventum => {
1919 match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1920 Ok((reticle, report)) => (
1921 reticle,
1922 json!({
1923 "dropped_elements": report.dropped_elements,
1924 "dropped_element_types": report
1925 .dropped_element_types
1926 .iter()
1927 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1928 .collect::<Vec<_>>(),
1929 "arcs": report
1934 .arcs
1935 .iter()
1936 .map(|arc| json!({
1937 "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1938 "radius_mil": arc.radius_mil,
1939 "start_degrees": arc.start_degrees,
1940 "end_degrees": arc.end_degrees,
1941 "sweep_degrees": arc.sweep_degrees,
1942 "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1943 "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1944 "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1945 }))
1946 .collect::<Vec<_>>(),
1947 "arcs_unresolved": report.arcs_unresolved,
1952 "circle_repeats_unreadable": report.circle_repeats_unreadable,
1953 }),
1954 "ventum",
1955 ),
1956 Err(err) => return reticle_error_envelope("reticle.import", &err),
1957 }
1958 }
1959 ReticleDocumentFormat::ReticleXml => {
1960 match crate::reticle_document_import::import_reticle_document_with_report(
1961 &request.document,
1962 ) {
1963 Ok((reticle, report)) => (
1964 reticle,
1965 json!({
1966 "drawing_elements_dropped": report.drawing_elements_dropped,
1967 "dropped_element_tags": report
1968 .dropped_element_tags
1969 .iter()
1970 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1971 .collect::<Vec<_>>(),
1972 "canvas_mil": report
1973 .canvas_mil
1974 .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1975 "zero_in_canvas_mil": report
1976 .zero_in_canvas_mil
1977 .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1978 }),
1979 "reticle_xml",
1980 ),
1981 Err(err) => return reticle_error_envelope("reticle.import", &err),
1982 }
1983 }
1984 };
1985
1986 match serde_json::to_value(&reticle) {
1987 Ok(imported) => success(
1988 "reticle.import",
1989 json!({
1990 "reticle": imported,
1991 "format": format,
1992 "mark_count": reticle.marks.len(),
1993 "report": report,
1994 }),
1995 ),
1996 Err(err) => error(
1997 BridgeErrorCode::InternalError,
1998 format!("failed to serialize imported reticle: {err}"),
1999 None,
2000 ),
2001 }
2002}
2003
2004#[cfg(test)]
2005mod tests {
2006 use super::*;
2007
2008 fn call(value: Value) -> Value {
2009 let raw = bridge_call(&value.to_string());
2010 serde_json::from_str(&raw).expect("bridge output must be valid JSON")
2011 }
2012
2013 #[test]
2014 fn capabilities_reports_commands_and_versions() {
2015 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2016 assert_eq!(out["ok"], true);
2017 assert_eq!(out["api_version"], 1);
2018 assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
2019 let commands: Vec<String> =
2020 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2021 assert!(commands.contains(&"solve".to_string()));
2022 assert!(commands.contains(&"meta.capabilities".to_string()));
2023 }
2024
2025 #[test]
2026 fn density_altitude_is_listed_right_after_solve() {
2027 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2028 let commands: Vec<String> =
2029 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2030 let solve = commands.iter().position(|c| c == "solve").unwrap();
2031 assert_eq!(
2032 commands.get(solve + 1).map(String::as_str),
2033 Some("atmosphere.density_altitude"),
2034 "{commands:?}"
2035 );
2036 }
2037
2038 #[test]
2039 fn density_altitude_reports_both_definitions_at_icao_standard_humid_air() {
2040 let out = call(json!({
2041 "api_version": 1,
2042 "command": "atmosphere.density_altitude",
2043 "request": {"atmosphere": {
2044 "altitude_m": 0.0,
2045 "temperature_k": 288.15,
2046 "pressure_pa": 101325.0,
2047 "relative_humidity": 0.5,
2048 }},
2049 }));
2050 assert_eq!(out["ok"], true, "{out}");
2051 assert_eq!(out["command"], "atmosphere.density_altitude");
2052 let result = &out["result"];
2053 let faa = result["density_altitude"]["faa_rule"]["m"]
2054 .as_f64()
2055 .unwrap();
2056 let matched = result["density_altitude"]["density_matched"]["m"]
2057 .as_f64()
2058 .unwrap();
2059 assert!(faa.abs() < 1e-9, "{result}");
2060 assert!((matched - 33.10).abs() < 0.2, "{result}");
2061 let ft = result["density_altitude"]["density_matched"]["ft"]
2062 .as_f64()
2063 .unwrap();
2064 assert!((ft * 0.3048 - matched).abs() < 1e-9, "{result}");
2065 assert!(result["air_density_kg_m3"].as_f64().unwrap() > 1.2);
2066 assert_eq!(result["assumptions"], json!([]), "{result}");
2068 assert_eq!(result["atmosphere"]["pressure_pa"], 101325.0);
2069 }
2070
2071 #[test]
2072 fn density_altitude_empty_atmosphere_resolves_to_announced_icao_defaults() {
2073 let out = call(json!({
2074 "api_version": 1,
2075 "command": "atmosphere.density_altitude",
2076 "request": {"atmosphere": {}},
2077 }));
2078 assert_eq!(out["ok"], true, "{out}");
2079 let assumptions = out["result"]["assumptions"].as_array().unwrap();
2080 assert!(!assumptions.is_empty(), "{out}");
2081 for notice in assumptions {
2082 assert!(
2083 notice["path"]
2084 .as_str()
2085 .unwrap_or_default()
2086 .starts_with("$.atmosphere."),
2087 "{notice}"
2088 );
2089 }
2090 }
2091
2092 #[test]
2093 fn density_altitude_without_payload_is_invalid_request() {
2094 let out = call(json!({"api_version": 1, "command": "atmosphere.density_altitude"}));
2095 assert_eq!(out["ok"], false);
2096 assert_eq!(out["error"]["code"], "invalid_request");
2097 }
2098
2099 #[test]
2100 fn density_altitude_rejects_a_unit_suffixed_field_at_its_path() {
2101 let out = call(json!({
2105 "api_version": 1,
2106 "command": "atmosphere.density_altitude",
2107 "request": {"atmosphere": {"temperature_c": 30.0}},
2108 }));
2109 assert_eq!(out["ok"], false, "{out}");
2110 assert_eq!(out["error"]["code"], "command_failed");
2111 assert_eq!(out["error"]["details"]["error"]["code"], "unknown_field");
2112 assert_eq!(
2113 out["error"]["details"]["error"]["path"],
2114 "$.atmosphere.temperature_c"
2115 );
2116 }
2117
2118 #[test]
2119 fn density_altitude_refuses_celsius_sent_as_kelvin() {
2120 let out = call(json!({
2121 "api_version": 1,
2122 "command": "atmosphere.density_altitude",
2123 "request": {"atmosphere": {"temperature_k": 15.0}},
2124 }));
2125 assert_eq!(out["ok"], false, "{out}");
2126 assert_eq!(out["error"]["code"], "command_failed");
2127 assert_eq!(
2128 out["error"]["details"]["error"]["path"],
2129 "$.atmosphere.temperature_k"
2130 );
2131 }
2132
2133 #[test]
2134 fn density_altitude_humidity_as_percent_is_located_in_the_solve_envelope() {
2135 let out = call(json!({
2136 "api_version": 1,
2137 "command": "atmosphere.density_altitude",
2138 "request": {"atmosphere": {"relative_humidity": 50.0}},
2139 }));
2140 assert_eq!(out["ok"], false, "{out}");
2141 assert_eq!(out["error"]["code"], "command_failed");
2142 assert!(
2143 out["error"]["details"]
2144 .to_string()
2145 .contains("$.atmosphere.relative_humidity"),
2146 "{out}"
2147 );
2148 }
2149
2150 #[test]
2151 fn invalid_json_is_an_envelope_not_a_panic() {
2152 let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
2153 assert_eq!(out["ok"], false);
2154 assert_eq!(out["error"]["code"], "invalid_json");
2155 }
2156
2157 #[test]
2158 fn unknown_envelope_field_is_rejected() {
2159 let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
2160 assert_eq!(out["ok"], false);
2161 assert_eq!(out["error"]["code"], "invalid_json");
2162 }
2163
2164 #[test]
2165 fn unknown_command_lists_supported_ones() {
2166 let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
2169 assert_eq!(out["error"]["code"], "unknown_command");
2170 assert!(out["error"]["message"]
2171 .as_str()
2172 .unwrap()
2173 .contains("meta.capabilities"));
2174 }
2175
2176 #[test]
2177 fn wrong_api_version_is_rejected() {
2178 let out = call(json!({"api_version": 99, "command": "meta.version"}));
2179 assert_eq!(out["error"]["code"], "unsupported_api_version");
2180 }
2181
2182 #[test]
2183 fn oversize_request_is_a_resource_limit() {
2184 let big = format!(
2185 r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
2186 "x".repeat(MAX_REQUEST_BYTES)
2187 );
2188 let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
2189 assert_eq!(out["error"]["code"], "resource_limit");
2190 }
2191
2192 #[test]
2193 fn solve_without_payload_is_invalid_request() {
2194 let out = call(json!({"api_version": 1, "command": "solve"}));
2195 assert_eq!(out["error"]["code"], "invalid_request");
2196 }
2197
2198 #[test]
2199 fn profile_commands_without_payload_are_invalid_requests() {
2200 for command in ["profile.validate", "profile.normalize"] {
2201 let out = call(json!({"api_version": 1, "command": command}));
2202 assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
2203 assert!(
2204 out["error"]["message"]
2205 .as_str()
2206 .unwrap()
2207 .contains("ProfileData"),
2208 "{command}: {out}"
2209 );
2210 }
2211 }
2212
2213 #[test]
2214 fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
2215 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2216 let commands: Vec<String> =
2217 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2218 assert!(commands.contains(&"profile.validate".to_string()));
2219 assert!(commands.contains(&"profile.normalize".to_string()));
2220 assert_eq!(
2221 commands.contains(&"profile.import_a7p".to_string()),
2222 cfg!(feature = "profile-import"),
2223 "profile.import_a7p must be listed exactly when compiled in"
2224 );
2225 assert_eq!(
2226 commands.contains(&"profile.export_a7p".to_string()),
2227 cfg!(feature = "profile-export"),
2228 "profile.export_a7p must be listed exactly when compiled in"
2229 );
2230 assert_eq!(
2231 commands.contains(&"bc5d.info".to_string()),
2232 cfg!(not(target_arch = "wasm32")),
2233 "bc5d.info must be listed exactly when the build has filesystem access"
2234 );
2235 }
2236
2237 #[cfg(not(target_arch = "wasm32"))]
2238 #[test]
2239 fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
2240 let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
2241 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2242
2243 let out = call(json!({
2244 "api_version": 1,
2245 "command": "bc5d.info",
2246 "request": {"path": "/nonexistent/bc5d_308.bin"}
2247 }));
2248 assert_eq!(out["error"]["code"], "command_failed", "{out}");
2249 assert!(
2250 out["error"]["message"]
2251 .as_str()
2252 .unwrap()
2253 .contains("not a usable BC5D table"),
2254 "{out}"
2255 );
2256 }
2257
2258 #[cfg(feature = "profile-import")]
2259 #[test]
2260 fn base64_decoder_round_trips_and_rejects_garbage() {
2261 for (text, bytes) in [
2263 ("", &b""[..]),
2264 ("Zg==", b"f"),
2265 ("Zm8=", b"fo"),
2266 ("Zm9v", b"foo"),
2267 ("Zm9vYg==", b"foob"),
2268 ("Zm9vYmE=", b"fooba"),
2269 ("Zm9vYmFy", b"foobar"),
2270 ] {
2271 assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
2272 }
2273 assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
2274 assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
2275 assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
2276 assert!(
2277 decode_base64("Zm9v!").is_err(),
2278 "non-alphabet byte is rejected"
2279 );
2280 }
2281
2282 #[test]
2286 fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2287 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2288 let commands: Vec<String> =
2289 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2290 assert_eq!(
2291 commands.contains(&"card.pdf".to_string()),
2292 cfg!(feature = "pdf"),
2293 "card.pdf must be listed exactly when compiled in: {out}"
2294 );
2295 let features: Vec<String> =
2296 serde_json::from_value(out["result"]["features"].clone()).unwrap();
2297 assert_eq!(
2298 features.contains(&"pdf".to_string()),
2299 cfg!(feature = "pdf"),
2300 "the command list and the feature list must agree: {out}"
2301 );
2302 }
2303
2304 #[cfg(not(feature = "pdf"))]
2305 #[test]
2306 fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2307 let out = call(json!({
2308 "api_version": 1,
2309 "command": "card.pdf",
2310 "request": {
2311 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2312 "mass": 175.0, "diameter": 0.308,
2313 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2314 }
2315 }));
2316 assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2317 }
2318
2319 #[test]
2323 fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2324 let out = call(json!({
2325 "api_version": 1,
2326 "command": "card.range_table",
2327 "request": {
2328 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2329 "mass": 175.0, "diameter": 0.308,
2330 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2331 "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2332 }
2333 }));
2334 assert_eq!(out["ok"], true, "{out}");
2335 assert_eq!(out["result"]["kind"], "range_table", "{out}");
2336 }
2337
2338 #[cfg(feature = "pdf")]
2339 #[test]
2340 fn card_pdf_without_payload_is_invalid_request() {
2341 let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2342 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2343 assert!(
2344 out["error"]["message"]
2345 .as_str()
2346 .unwrap()
2347 .contains("card v1 document"),
2348 "{out}"
2349 );
2350 }
2351
2352 #[cfg(feature = "pdf")]
2356 #[test]
2357 fn pdf_output_cap_refuses_only_over_the_limit() {
2358 assert!(pdf_over_cap_error(0, 0, 0).is_none());
2359 assert!(
2360 pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2361 "a document exactly at the cap fits"
2362 );
2363 let envelope: Value =
2364 serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2365 .unwrap();
2366 assert_eq!(envelope["ok"], false);
2367 assert_eq!(envelope["error"]["code"], "resource_limit");
2368 let message = envelope["error"]["message"].as_str().unwrap();
2369 assert!(message.contains("dope card"), "{envelope}");
2370 assert!(message.contains("6 rows"), "{envelope}");
2372 assert!(message.contains("1 pages"), "{envelope}");
2373 for absent in ["coarsen", "shorten"] {
2374 assert!(!message.contains(absent), "{envelope}");
2375 }
2376 }
2377
2378 #[cfg(feature = "pdf")]
2379 #[test]
2380 fn base64_encoder_matches_the_rfc_4648_vectors() {
2381 for (bytes, text) in [
2382 (&b""[..], ""),
2383 (b"f", "Zg=="),
2384 (b"fo", "Zm8="),
2385 (b"foo", "Zm9v"),
2386 (b"foob", "Zm9vYg=="),
2387 (b"fooba", "Zm9vYmE="),
2388 (b"foobar", "Zm9vYmFy"),
2389 ] {
2390 assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2391 }
2392 assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2395 assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2396 assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2397 }
2398
2399 #[cfg(all(feature = "pdf", feature = "profile-import"))]
2402 #[test]
2403 fn base64_encode_decode_round_trips_arbitrary_bytes() {
2404 for len in 0..=32usize {
2405 let bytes: Vec<u8> = (0..len)
2406 .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2407 .collect();
2408 let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2409 assert_eq!(decoded, bytes, "len {len}");
2410 }
2411 }
2412
2413 #[test]
2414 fn solve_with_bad_schema_carries_typed_details() {
2415 let out = call(json!({
2416 "api_version": 1,
2417 "command": "solve",
2418 "request": {"schema_version": 1, "unknown_field": true}
2419 }));
2420 assert_eq!(out["error"]["code"], "command_failed");
2421 assert_eq!(out["error"]["details"]["status"], "error");
2423 }
2424
2425 fn hash_reticle() -> Value {
2432 json!({
2433 "name": "test cross",
2434 "focal_plane": "ffp",
2435 "reference_magnification": 1.0,
2436 "marks": [
2437 {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2438 {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2439 {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2440 {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2441 ],
2442 })
2443 }
2444
2445 #[test]
2446 fn capabilities_lists_the_reticle_family() {
2447 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2450 let commands: Vec<String> =
2451 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2452 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2453 assert!(
2454 commands.contains(&command.to_string()),
2455 "{command} dispatches but meta.capabilities does not list it"
2456 );
2457 }
2458 }
2459
2460 #[test]
2461 fn hold_reports_the_nearest_mark_and_its_label() {
2462 let out = call(json!({
2463 "api_version": 1,
2464 "command": "reticle.hold",
2465 "request": {
2466 "reticle": hash_reticle(),
2467 "drop_mil": 2.1,
2468 "wind_mil": 0.0,
2469 "magnification": 10.0,
2470 },
2471 }));
2472 assert_eq!(out["ok"], true, "{out}");
2473 assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2476 assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2477 assert_eq!(out["result"]["hold"]["off_reticle"], false);
2478 assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2479 assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2480 assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2481 }
2482
2483 #[test]
2484 fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2485 let mut sfp = hash_reticle();
2489 sfp["focal_plane"] = json!("sfp");
2490 sfp["reference_magnification"] = json!(10.0);
2491
2492 let at_reference = call(json!({
2493 "api_version": 1, "command": "reticle.hold",
2494 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2495 }));
2496 assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2497 assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2498 assert_eq!(
2499 at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2500 2.0
2501 );
2502
2503 let zoomed_out = call(json!({
2506 "api_version": 1, "command": "reticle.hold",
2507 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2508 }));
2509 assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2510 assert_eq!(
2511 zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2512 "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2513 );
2514 assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2515 assert_eq!(
2517 zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2518 0.0
2519 );
2520
2521 let on_the_scaled_hash = call(json!({
2529 "api_version": 1, "command": "reticle.hold",
2530 "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2531 }));
2532 assert_eq!(
2533 on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2534 "as authored, the mark is at 2 mil"
2535 );
2536 assert_eq!(
2537 on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2538 "at 5x it subtends 4 mil, which is what the hold is measured against"
2539 );
2540 assert_eq!(
2541 on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2542 "the hold lands exactly on it"
2543 );
2544 }
2545
2546 #[test]
2547 fn a_hold_off_the_marked_part_says_so() {
2548 let out = call(json!({
2551 "api_version": 1, "command": "reticle.hold",
2552 "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2553 }));
2554 assert_eq!(out["ok"], true);
2555 assert_eq!(out["result"]["hold"]["off_reticle"], true);
2556 }
2557
2558 #[test]
2559 fn describe_builds_from_a_generator_and_reports_the_plane() {
2560 let out = call(json!({
2561 "api_version": 1, "command": "reticle.describe",
2562 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2563 }));
2564 assert_eq!(out["ok"], true, "{out}");
2565 assert_eq!(out["result"]["focal_plane"], "FFP");
2566 assert_eq!(out["result"]["magnification_dependent"], false);
2567 assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2568 assert!(out["result"]["scaled"].is_null());
2570 }
2571
2572 #[test]
2573 fn a_generated_reticle_can_be_made_sfp() {
2574 let out = call(json!({
2578 "api_version": 1, "command": "reticle.describe",
2579 "request": {
2580 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2581 "focal_plane": "sfp",
2582 "reference_magnification": 12.0,
2583 "magnification": 6.0,
2584 },
2585 }));
2586 assert_eq!(out["ok"], true, "{out}");
2587 assert_eq!(out["result"]["focal_plane"], "SFP");
2588 assert_eq!(out["result"]["magnification_dependent"], true);
2589 assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2590 }
2591
2592 #[test]
2593 fn a_bdc_ladder_carries_its_range_labels() {
2594 let out = call(json!({
2595 "api_version": 1, "command": "reticle.describe",
2596 "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2597 }));
2598 assert_eq!(out["ok"], true, "{out}");
2599 let marks = out["result"]["reticle"]["marks"]
2600 .as_array()
2601 .unwrap()
2602 .clone();
2603 let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2604 assert_eq!(labels, vec!["300 m", "400 m"]);
2606 }
2607
2608 #[test]
2609 fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2610 let out = call(json!({
2612 "api_version": 1, "command": "reticle.hold",
2613 "request": {
2614 "reticle": hash_reticle(),
2615 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2616 "drop_mil": 1.0,
2617 "magnification": 10.0,
2618 },
2619 }));
2620 assert_eq!(out["ok"], false);
2621 assert_eq!(out["error"]["code"], "invalid_request");
2622 assert!(out["error"]["message"]
2623 .as_str()
2624 .unwrap()
2625 .contains("exactly one"));
2626 }
2627
2628 #[test]
2629 fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2630 let out = call(json!({
2631 "api_version": 1, "command": "reticle.hold",
2632 "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2633 }));
2634 assert_eq!(out["ok"], true, "{out}");
2635 assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2637 assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2638 assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2639 }
2640
2641 #[test]
2642 fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2643 let out = call(json!({
2646 "api_version": 1, "command": "reticle.hold",
2647 "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2648 }));
2649 assert_eq!(out["ok"], false);
2650 assert_eq!(out["error"]["code"], "invalid_request");
2651 assert!(out["error"]["message"]
2652 .as_str()
2653 .unwrap()
2654 .contains("reticle.catalog"));
2655 }
2656
2657 #[test]
2658 fn naming_a_reticle_three_ways_is_refused_too() {
2659 let out = call(json!({
2661 "api_version": 1, "command": "reticle.describe",
2662 "request": {
2663 "catalog": "mil-dot",
2664 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2665 },
2666 }));
2667 assert_eq!(out["ok"], false);
2668 assert_eq!(out["error"]["code"], "invalid_request");
2669 }
2670
2671 #[test]
2672 fn the_catalog_lists_what_the_other_commands_will_accept() {
2673 let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2676 assert_eq!(out["ok"], true, "{out}");
2677 let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2678 assert!(!reticles.is_empty());
2679 for entry in reticles {
2680 let id = entry["id"].as_str().unwrap();
2681 assert!(
2683 !entry["source"].as_str().unwrap().is_empty(),
2684 "{id} has no source"
2685 );
2686 assert!(
2687 !entry["notes"].as_str().unwrap().is_empty(),
2688 "{id} has no notes"
2689 );
2690 assert!(
2691 entry["mark_count"].as_u64().unwrap() > 0,
2692 "{id} has no marks"
2693 );
2694
2695 let held = call(json!({
2696 "api_version": 1, "command": "reticle.hold",
2697 "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2698 }));
2699 assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2700 }
2701 }
2702
2703 #[test]
2704 fn a_batch_of_holds_comes_back_in_request_order() {
2705 let out = call(json!({
2707 "api_version": 1, "command": "reticle.holds",
2708 "request": {
2709 "catalog": "mil-dot",
2710 "magnification": 10.0,
2711 "holds": [
2712 {"drop_mil": 1.0},
2713 {"drop_mil": 3.0, "wind_mil": 0.0},
2714 {"drop_mil": 2.0},
2715 ],
2716 },
2717 }));
2718 assert_eq!(out["ok"], true, "{out}");
2719 let holds = out["holds"].clone();
2720 assert!(holds.is_null(), "holds live under result, not the envelope");
2721 let holds = out["result"]["holds"].as_array().unwrap().clone();
2722 assert_eq!(holds.len(), 3);
2723 assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2725 assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2726 assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2727 assert_eq!(out["result"]["mark_scale"], 1.0);
2729 assert!(holds[0]["hold"]["mark_scale"].is_null());
2730 }
2731
2732 #[test]
2733 fn a_batch_agrees_with_the_single_hold_command() {
2734 let single = call(json!({
2737 "api_version": 1, "command": "reticle.hold",
2738 "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2739 }));
2740 let batch = call(json!({
2741 "api_version": 1, "command": "reticle.holds",
2742 "request": {
2743 "catalog": "mil-dot", "magnification": 10.0,
2744 "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2745 },
2746 }));
2747 assert_eq!(single["ok"], true);
2748 assert_eq!(batch["ok"], true);
2749 assert_eq!(
2750 single["result"]["nearest_mark"],
2751 batch["result"]["holds"][0]["nearest_mark"]
2752 );
2753 assert_eq!(
2754 single["result"]["hold"]["off_reticle"],
2755 batch["result"]["holds"][0]["hold"]["off_reticle"]
2756 );
2757 assert_eq!(
2758 single["result"]["hold"]["nearest_mark_distance_mil"],
2759 batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2760 );
2761 }
2762
2763 #[test]
2764 fn a_malformed_hold_entry_refuses_the_whole_batch() {
2765 for bad in [
2778 json!(null),
2779 json!("3.0"),
2780 json!({"drop_mil": 1.0, "nonsense": 2}),
2781 ] {
2782 let out = call(json!({
2783 "api_version": 1, "command": "reticle.holds",
2784 "request": {
2785 "catalog": "mil-dot", "magnification": 10.0,
2786 "holds": [{"drop_mil": 1.0}, bad],
2787 },
2788 }));
2789 assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2790 assert_eq!(out["error"]["code"], "invalid_request");
2791 }
2792 }
2793
2794 #[test]
2795 fn a_bad_magnification_fails_the_batch_before_any_row() {
2796 let out = call(json!({
2798 "api_version": 1, "command": "reticle.holds",
2799 "request": {
2800 "catalog": "mil-dot", "magnification": 0.0,
2801 "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2802 },
2803 }));
2804 assert_eq!(out["ok"], false);
2805 assert_eq!(
2806 out["error"]["details"]["reason"],
2807 "non_positive_magnification"
2808 );
2809 }
2810
2811 #[test]
2812 fn an_empty_batch_is_an_empty_list_not_an_error() {
2813 let out = call(json!({
2815 "api_version": 1, "command": "reticle.holds",
2816 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2817 }));
2818 assert_eq!(out["ok"], true, "{out}");
2819 assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2820 }
2821
2822 #[test]
2823 fn too_many_holds_is_a_resource_limit() {
2824 let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2825 .map(|i| json!({"drop_mil": (i % 5) as f64}))
2826 .collect();
2827 let out = call(json!({
2828 "api_version": 1, "command": "reticle.holds",
2829 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2830 }));
2831 assert_eq!(out["ok"], false);
2832 assert_eq!(out["error"]["code"], "resource_limit");
2833 }
2834
2835 #[test]
2836 fn capabilities_lists_the_catalog_command() {
2837 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2838 let commands: Vec<String> =
2839 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2840 assert!(commands.contains(&"reticle.catalog".to_string()));
2841 assert!(commands.contains(&"reticle.holds".to_string()));
2842 }
2843
2844 #[test]
2845 fn naming_no_reticle_at_all_is_refused() {
2846 let out = call(json!({
2847 "api_version": 1, "command": "reticle.hold",
2848 "request": {"drop_mil": 1.0, "magnification": 10.0},
2849 }));
2850 assert_eq!(out["ok"], false);
2851 assert_eq!(out["error"]["code"], "invalid_request");
2852 }
2853
2854 #[test]
2855 fn plane_overrides_beside_a_full_reticle_are_refused() {
2856 let out = call(json!({
2859 "api_version": 1, "command": "reticle.describe",
2860 "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2861 }));
2862 assert_eq!(out["ok"], false);
2863 assert_eq!(out["error"]["code"], "invalid_request");
2864 }
2865
2866 #[test]
2867 fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2868 let out = call(json!({
2872 "api_version": 1, "command": "reticle.hold",
2873 "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2874 }));
2875 assert_eq!(out["ok"], false);
2876 assert_eq!(out["error"]["code"], "command_failed");
2877 assert_eq!(
2878 out["error"]["details"]["reason"],
2879 "non_positive_magnification"
2880 );
2881 assert_eq!(out["error"]["details"]["magnification"], 0.0);
2882 assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2883 }
2884
2885 #[test]
2886 fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2887 let mut empty = hash_reticle();
2890 empty["marks"] = json!([]);
2891 let out = call(json!({
2892 "api_version": 1, "command": "reticle.describe",
2893 "request": {"reticle": empty},
2894 }));
2895 assert_eq!(out["ok"], false);
2896 assert_eq!(out["error"]["details"]["reason"], "no_marks");
2897 }
2898
2899 #[test]
2900 fn an_unreadable_generator_parameter_names_the_parameter() {
2901 let out = call(json!({
2902 "api_version": 1, "command": "reticle.describe",
2903 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2904 }));
2905 assert_eq!(out["ok"], false);
2906 assert_eq!(
2907 out["error"]["details"]["reason"],
2908 "invalid_generator_parameter"
2909 );
2910 assert_eq!(out["error"]["details"]["parameter"], "spacing");
2911 }
2912
2913 #[test]
2914 fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2915 let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2921 {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2922 {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2923 {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2924
2925 let out = call(json!({
2926 "api_version": 1, "command": "reticle.import",
2927 "request": {"format": "ventum", "document": document},
2928 }));
2929 assert_eq!(out["ok"], true, "{out}");
2930 assert_eq!(out["result"]["format"], "ventum");
2931 assert_eq!(out["result"]["mark_count"], 78);
2932 assert_eq!(out["result"]["reticle"]["name"], "MBR");
2933 assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2934 assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2936 assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2937
2938 let held = call(json!({
2940 "api_version": 1, "command": "reticle.hold",
2941 "request": {
2942 "reticle": out["result"]["reticle"],
2943 "drop_mil": 8.0,
2944 "wind_mil": 2.0,
2945 "magnification": 10.0,
2946 },
2947 }));
2948 assert_eq!(held["ok"], true, "{held}");
2949 assert_eq!(held["result"]["hold"]["off_reticle"], false);
2950 assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2951 assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2952 assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2953 }
2954
2955 #[test]
2956 fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2957 let out = call(json!({
2958 "api_version": 1, "command": "reticle.import",
2959 "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2960 }));
2961 assert_eq!(out["ok"], false);
2962 assert_eq!(out["error"]["code"], "command_failed");
2963 }
2964
2965 #[test]
2966 fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2967 let out = call(json!({
2970 "api_version": 1, "command": "reticle.import",
2971 "request": {
2972 "format": "ventum",
2973 "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2974 },
2975 }));
2976 assert_eq!(out["ok"], false);
2977 assert_eq!(out["error"]["code"], "resource_limit");
2978 assert!(out["error"]["message"]
2979 .as_str()
2980 .unwrap()
2981 .contains("reticle document"));
2982 }
2983
2984 #[test]
2985 fn every_reticle_command_rejects_an_unknown_request_field() {
2986 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2987 let out = call(json!({
2988 "api_version": 1,
2989 "command": command,
2990 "request": {"nonsense": 1},
2991 }));
2992 assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2993 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2994 }
2995 }
2996
2997 #[test]
2998 fn every_reticle_command_rejects_a_missing_payload() {
2999 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
3000 let out = call(json!({"api_version": 1, "command": command}));
3001 assert_eq!(out["ok"], false, "{command} accepted a null payload");
3002 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
3003 }
3004 }
3005
3006 #[cfg(all(feature = "profile-export", feature = "profile-import"))]
3011 #[test]
3012 fn export_a7p_produces_a_file_import_a7p_reads_back() {
3013 let profile = json!({
3014 "name": "bridge-export",
3015 "velocity": 792.0,
3016 "bc": 0.381,
3017 "mass": 19.439673,
3018 "diameter": 8.5852,
3019 "drag_model": "G7",
3020 "twist_rate": 254.0,
3021 "sight_height": 90.0,
3022 "zero_distance": 100.0,
3023 "units": "metric",
3024 "temperature": 15.0,
3025 "pressure": 1000.0,
3026 "humidity": 50.0,
3027 "altitude": 0.0,
3028 "bullet_name": "300GR OTM",
3029 "twist_right": false,
3030 "bullet_length": 45.72,
3031 "elevation_cf": 0.97,
3032 "dsf_points": [{"mach": 0.9, "dsf": 1.04}]
3033 });
3034
3035 let out = call(json!({
3036 "api_version": 1, "command": "profile.export_a7p", "request": profile
3037 }));
3038 assert_eq!(out["ok"], true, "{out}");
3039 let result = &out["result"];
3040 assert!(result["byte_length"].as_u64().unwrap() > 32);
3041
3042 let dropped: Vec<String> =
3045 serde_json::from_value(result["dropped_fields"].clone()).unwrap();
3046 assert!(dropped.contains(&"elevation_cf".to_string()), "{out}");
3047 assert!(dropped.contains(&"dsf_points".to_string()), "{out}");
3048 assert!(!dropped.contains(&"velocity".to_string()), "{out}");
3049 let not_carried = result["not_carried"].as_array().unwrap();
3052 let reticle = not_carried
3053 .iter()
3054 .find(|e| e["field"] == "reticle")
3055 .expect("reticle is listed even though this profile has none");
3056 assert_eq!(reticle["populated"], false, "{out}");
3057 assert!(reticle["reason"].as_str().unwrap().len() > 10, "{out}");
3058
3059 let back = call(json!({
3060 "api_version": 1,
3061 "command": "profile.import_a7p",
3062 "request": { "a7p_base64": result["a7p_base64"].clone(), "strict": true }
3063 }));
3064 assert_eq!(back["ok"], true, "{back}");
3067 let reimported = &back["result"]["profile"];
3068 assert_eq!(reimported["name"], "bridge-export", "{back}");
3069 assert_eq!(reimported["drag_model"], "G7", "{back}");
3070 assert_eq!(reimported["twist_right"], false, "{back}");
3071 }
3072
3073 #[cfg(feature = "profile-export")]
3076 #[test]
3077 fn export_a7p_refuses_an_unrepresentable_drag_model_with_a_reason() {
3078 let out = call(json!({
3079 "api_version": 1,
3080 "command": "profile.export_a7p",
3081 "request": {
3086 "name": "g5-load", "velocity": 792.0, "bc": 0.3, "mass": 19.4,
3087 "diameter": 8.5852, "drag_model": "G5", "units": "metric",
3088 "bullet_length": 45.72, "zero_distance": 100.0, "sight_height": 90.0,
3089 "temperature": 15.0, "pressure": 1000.0, "humidity": 50.0
3090 }
3091 }));
3092 assert_eq!(out["error"]["code"], "command_failed", "{out}");
3093 assert_eq!(out["error"]["details"]["reason"], "drag_model", "{out}");
3094 assert_eq!(out["error"]["details"]["drag_model"], "G5", "{out}");
3095 }
3096
3097 #[cfg(feature = "profile-export")]
3098 #[test]
3099 fn export_a7p_without_a_payload_is_an_invalid_request() {
3100 let out = call(json!({"api_version": 1, "command": "profile.export_a7p"}));
3101 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
3102 assert!(
3103 out["error"]["message"]
3104 .as_str()
3105 .unwrap()
3106 .contains("ProfileData"),
3107 "{out}"
3108 );
3109 }
3110}