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