1#[cfg(feature = "ffi")]
118pub mod ffi;
119
120use serde::{Deserialize, Serialize};
121use serde_json::{json, Value};
122use std::panic::{catch_unwind, AssertUnwindSafe};
123
124pub const BRIDGE_API_VERSION: u32 = 1;
126
127pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
129
130const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
131
132fn command_names() -> Vec<&'static str> {
135 let mut names = vec![
136 "meta.capabilities",
137 "meta.version",
138 "solve",
139 "card.come_ups",
140 "card.range_table",
141 "card.wind",
142 ];
143 #[cfg(feature = "pdf")]
147 names.push("card.pdf");
148 names.extend(["profile.validate", "profile.normalize"]);
149 #[cfg(feature = "profile-import")]
150 names.push("profile.import_a7p");
151 names.extend([
154 "reticle.catalog",
155 "reticle.describe",
156 "reticle.hold",
157 "reticle.holds",
158 "reticle.import",
159 ]);
160 names.extend([
161 "true.fit",
162 "true.wind",
163 "true.tall_target",
164 "true.dsf",
165 "true.plan",
166 "true.dial_plan",
167 ]);
168 #[cfg(not(target_arch = "wasm32"))]
171 names.push("bc5d.info");
172 names
173}
174
175fn compiled_features() -> Vec<&'static str> {
176 [
177 ("pdf", cfg!(feature = "pdf")),
178 ("profile-import", cfg!(feature = "profile-import")),
179 ("online", cfg!(feature = "online")),
180 ]
181 .iter()
182 .filter(|(_, enabled)| *enabled)
183 .map(|(name, _)| *name)
184 .collect()
185}
186
187#[derive(Debug, Deserialize)]
188#[serde(deny_unknown_fields)]
189struct BridgeRequest {
190 api_version: u32,
191 command: String,
192 #[serde(default)]
193 request: Value,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
199#[serde(rename_all = "snake_case")]
200pub enum BridgeErrorCode {
201 InvalidJson,
202 UnsupportedApiVersion,
203 UnknownCommand,
204 InvalidRequest,
205 ResourceLimit,
206 CommandFailed,
207 InternalError,
208}
209
210fn success(command: &str, result: Value) -> String {
211 serialize_envelope(&json!({
212 "ok": true,
213 "api_version": BRIDGE_API_VERSION,
214 "engine_version": ENGINE_VERSION,
215 "command": command,
216 "result": result,
217 }))
218}
219
220fn error(code: BridgeErrorCode, message: impl Into<String>, details: Option<Value>) -> String {
221 let mut error = json!({
222 "code": code,
223 "message": message.into(),
224 });
225 if let Some(details) = details {
226 error["details"] = details;
227 }
228 serialize_envelope(&json!({
229 "ok": false,
230 "api_version": BRIDGE_API_VERSION,
231 "engine_version": ENGINE_VERSION,
232 "error": error,
233 }))
234}
235
236fn serialize_envelope(value: &Value) -> String {
239 serde_json::to_string(value).unwrap_or_else(|_| {
240 format!(
241 r#"{{"ok":false,"api_version":{BRIDGE_API_VERSION},"engine_version":"{ENGINE_VERSION}","error":{{"code":"internal_error","message":"bridge response serialization failed"}}}}"#
242 )
243 })
244}
245
246pub fn bridge_call(request_json: &str) -> String {
249 let guarded = catch_unwind(AssertUnwindSafe(|| dispatch(request_json)));
250 guarded.unwrap_or_else(|_| {
251 error(
252 BridgeErrorCode::InternalError,
253 "bridge command failed unexpectedly",
254 None,
255 )
256 })
257}
258
259fn dispatch(request_json: &str) -> String {
260 if request_json.len() > MAX_REQUEST_BYTES {
261 return error(
262 BridgeErrorCode::ResourceLimit,
263 format!("bridge request exceeds the {MAX_REQUEST_BYTES}-byte limit"),
264 None,
265 );
266 }
267
268 let request: BridgeRequest = match serde_json::from_str(request_json) {
269 Ok(request) => request,
270 Err(err) => {
271 return error(
272 BridgeErrorCode::InvalidJson,
273 format!("bridge request is not a valid envelope: {err}"),
274 None,
275 )
276 }
277 };
278
279 if request.api_version != BRIDGE_API_VERSION {
280 return error(
281 BridgeErrorCode::UnsupportedApiVersion,
282 format!(
283 "unsupported api_version {}; this build speaks {BRIDGE_API_VERSION}",
284 request.api_version
285 ),
286 None,
287 );
288 }
289
290 match request.command.as_str() {
291 "meta.capabilities" => success(
292 "meta.capabilities",
293 json!({
294 "engine_version": ENGINE_VERSION,
295 "bridge_api_version": BRIDGE_API_VERSION,
296 "commands": command_names(),
297 "features": compiled_features(),
298 "solve_schema_version": crate::solve_json::SOLVE_JSON_SCHEMA_VERSION_V1,
299 }),
300 ),
301 "meta.version" => success("meta.version", json!({ "engine_version": ENGINE_VERSION })),
302 "solve" => run_solve(&request.request),
303 "card.come_ups" => run_service(
304 &request.request,
305 "card.come_ups",
306 crate::card_service::come_ups_v1,
307 ),
308 "card.range_table" => run_service(
309 &request.request,
310 "card.range_table",
311 crate::card_service::range_table_v1,
312 ),
313 "card.wind" => run_service(
314 &request.request,
315 "card.wind",
316 crate::card_service::wind_card_v1,
317 ),
318 #[cfg(feature = "pdf")]
319 "card.pdf" => run_card_pdf(&request.request),
320 "profile.validate" => run_profile_validate(&request.request),
321 "profile.normalize" => run_profile_normalize(&request.request),
322 #[cfg(feature = "profile-import")]
323 "profile.import_a7p" => run_profile_import_a7p(&request.request),
324 "reticle.catalog" => run_reticle_catalog(),
325 "reticle.describe" => run_reticle_describe(&request.request),
326 "reticle.hold" => run_reticle_hold(&request.request),
327 "reticle.holds" => run_reticle_holds(&request.request),
328 "reticle.import" => run_reticle_import(&request.request),
329 "true.fit" => run_service(
330 &request.request,
331 "true.fit",
332 crate::truing_uncertainty::run_uncertainty_truing_v1,
333 ),
334 "true.wind" => run_service(
335 &request.request,
336 "true.wind",
337 crate::truing_wind::solve_wind_truing,
338 ),
339 "true.tall_target" => run_service(
340 &request.request,
341 "true.tall_target",
342 crate::truing_service::tall_target_v1,
343 ),
344 "true.dsf" => run_service_detailed(
345 &request.request,
346 "true.dsf",
347 crate::truing_service::derive_dsf_point_v1,
348 crate::truing_service::DsfServiceErrorV1::failure_details,
349 ),
350 "true.plan" => run_service_detailed(
351 &request.request,
352 "true.plan",
353 crate::truing_plan::plan_truing_experiment_v1,
354 crate::truing_plan::TruingPlanErrorV1::failure_details,
355 ),
356 "true.dial_plan" => run_service_detailed(
357 &request.request,
358 "true.dial_plan",
359 crate::truing_service::dial_plan_v1,
360 crate::optic::OpticError::failure_details,
361 ),
362 #[cfg(not(target_arch = "wasm32"))]
363 "bc5d.info" => run_bc5d_info(&request.request),
364 other => error(
365 BridgeErrorCode::UnknownCommand,
366 format!(
367 "unknown command '{other}'; this build supports: {}",
368 command_names().join(", ")
369 ),
370 None,
371 ),
372 }
373}
374
375fn run_solve(inner: &Value) -> String {
380 if inner.is_null() {
381 return error(
382 BridgeErrorCode::InvalidRequest,
383 "'solve' requires a request payload (solve-json v1 document)",
384 None,
385 );
386 }
387 let inner_text = match serde_json::to_string(inner) {
388 Ok(text) => text,
389 Err(err) => {
390 return error(
391 BridgeErrorCode::InternalError,
392 format!("failed to re-serialize solve request: {err}"),
393 None,
394 )
395 }
396 };
397
398 let request = match crate::solve_json::decode_solve_request_v1(&inner_text) {
399 Ok(request) => request,
400 Err(envelope) => return command_error("solve request rejected", &envelope),
401 };
402
403 match crate::solve_v1(request) {
404 Ok(successful) => match serde_json::to_value(&successful) {
405 Ok(result) => success("solve", result),
406 Err(err) => error(
407 BridgeErrorCode::InternalError,
408 format!("failed to serialize solve result: {err}"),
409 None,
410 ),
411 },
412 Err(envelope) => command_error("solve failed", &envelope),
413 }
414}
415
416fn run_service<Req, Resp, E, F>(inner: &Value, command: &'static str, service: F) -> String
420where
421 Req: serde::de::DeserializeOwned,
422 Resp: serde::Serialize,
423 E: std::fmt::Display,
424 F: FnOnce(&Req) -> Result<Resp, E>,
425{
426 if inner.is_null() {
427 return error(
428 BridgeErrorCode::InvalidRequest,
429 format!("'{command}' requires a request payload"),
430 None,
431 );
432 }
433 let request: Req = match serde_json::from_value(inner.clone()) {
434 Ok(request) => request,
435 Err(err) => {
436 return error(
437 BridgeErrorCode::InvalidRequest,
438 format!("{command} request rejected: {err}"),
439 None,
440 )
441 }
442 };
443 match service(&request) {
444 Ok(response) => match serde_json::to_value(&response) {
445 Ok(result) => success(command, result),
446 Err(err) => error(
447 BridgeErrorCode::InternalError,
448 format!("failed to serialize {command} result: {err}"),
449 None,
450 ),
451 },
452 Err(err) => error(
453 BridgeErrorCode::CommandFailed,
454 format!("{command} failed: {err}"),
455 None,
456 ),
457 }
458}
459
460fn run_service_detailed<Req, Resp, E, F, D>(
469 inner: &Value,
470 command: &'static str,
471 service: F,
472 details: D,
473) -> String
474where
475 Req: serde::de::DeserializeOwned,
476 Resp: serde::Serialize,
477 E: std::fmt::Display,
478 F: FnOnce(&Req) -> Result<Resp, E>,
479 D: FnOnce(&E) -> Option<Value>,
480{
481 if inner.is_null() {
482 return error(
483 BridgeErrorCode::InvalidRequest,
484 format!("'{command}' requires a request payload"),
485 None,
486 );
487 }
488 let request: Req = match serde_json::from_value(inner.clone()) {
489 Ok(request) => request,
490 Err(err) => {
491 return error(
492 BridgeErrorCode::InvalidRequest,
493 format!("{command} request rejected: {err}"),
494 None,
495 )
496 }
497 };
498 match service(&request) {
499 Ok(response) => match serde_json::to_value(&response) {
500 Ok(result) => success(command, result),
501 Err(err) => error(
502 BridgeErrorCode::InternalError,
503 format!("failed to serialize {command} result: {err}"),
504 None,
505 ),
506 },
507 Err(err) => {
508 let d = details(&err);
509 error(
510 BridgeErrorCode::CommandFailed,
511 format!("{command} failed: {err}"),
512 d,
513 )
514 }
515 }
516}
517
518#[cfg(feature = "pdf")]
532pub const MAX_PDF_BYTES: usize = 4 * 1024 * 1024;
533
534#[cfg(feature = "pdf")]
543fn pdf_over_cap_error(byte_length: usize, row_count: usize, page_count: usize) -> Option<String> {
544 (byte_length > MAX_PDF_BYTES).then(|| {
545 error(
546 BridgeErrorCode::ResourceLimit,
547 format!(
548 "generated dope card is {byte_length} bytes; the limit is {MAX_PDF_BYTES} \
549 ({row_count} rows, {page_count} pages)"
550 ),
551 None,
552 )
553 })
554}
555
556#[cfg(feature = "pdf")]
563const STORED_CARD_KEY: &str = "stored_card";
564
565#[cfg(feature = "pdf")]
602fn run_card_pdf(inner: &Value) -> String {
603 use crate::card_service::CardServiceError;
604
605 if inner.is_null() {
606 return error(
607 BridgeErrorCode::InvalidRequest,
608 "'card.pdf' requires a request payload (card v1 document)",
609 None,
610 );
611 }
612 let mut payload = inner.clone();
613 let stored_value = payload
614 .as_object_mut()
615 .and_then(|object| object.remove(STORED_CARD_KEY))
616 .filter(|value| !value.is_null());
617 let request: crate::card_service::CardRequestV1 = match serde_json::from_value(payload) {
618 Ok(request) => request,
619 Err(err) => {
620 return error(
621 BridgeErrorCode::InvalidRequest,
622 format!("card.pdf request rejected: {err}"),
623 None,
624 )
625 }
626 };
627 let stored: Option<crate::card_service::StoredCardV1> = match stored_value {
628 Some(value) => match serde_json::from_value(value) {
629 Ok(stored) => Some(stored),
630 Err(err) => {
631 return error(
632 BridgeErrorCode::InvalidRequest,
633 format!("card.pdf {STORED_CARD_KEY} rejected: {err}"),
634 None,
635 )
636 }
637 },
638 None => None,
639 };
640
641 let card = match crate::card_service::pdf_card_v1(&request, stored.as_ref()) {
642 Ok(card) => card,
643 Err(err @ CardServiceError::TooLarge(_)) => {
646 return error(
647 BridgeErrorCode::ResourceLimit,
648 format!("card.pdf refused: {err}"),
649 None,
650 )
651 }
652 Err(err) => {
653 return error(
654 BridgeErrorCode::CommandFailed,
655 format!("card.pdf failed: {err}"),
656 None,
657 )
658 }
659 };
660 let byte_length = card.pdf_bytes.len();
661 if let Some(envelope) = pdf_over_cap_error(byte_length, card.row_count, card.page_count) {
662 return envelope;
663 }
664 let mut response = json!({
665 "pdf_base64": encode_base64(&card.pdf_bytes),
666 "byte_length": byte_length,
667 "page_count": card.page_count,
668 "row_count": card.row_count,
669 "kind": crate::card_service::PDF_CARD_KIND,
670 "source": card.source.as_str(),
671 "unprintable_title_chars": card.unprintable_title_chars,
672 });
673 if let (Some(truncation), Some(object)) = (card.truncation, response.as_object_mut()) {
679 object.insert(
680 "truncation".to_string(),
681 json!({
682 "requested_end": truncation.requested_end,
683 "last_row": truncation.last_row,
684 "reach": truncation.reach,
685 }),
686 );
687 }
688 success("card.pdf", response)
689}
690
691#[cfg(feature = "pdf")]
699fn encode_base64(bytes: &[u8]) -> String {
700 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
701 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
702 for chunk in bytes.chunks(3) {
703 let triple = (u32::from(chunk[0]) << 16)
704 | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8)
705 | u32::from(chunk.get(2).copied().unwrap_or(0));
706 out.push(char::from(ALPHABET[(triple >> 18) as usize & 63]));
707 out.push(char::from(ALPHABET[(triple >> 12) as usize & 63]));
708 out.push(if chunk.len() > 1 {
710 char::from(ALPHABET[(triple >> 6) as usize & 63])
711 } else {
712 '='
713 });
714 out.push(if chunk.len() > 2 {
715 char::from(ALPHABET[triple as usize & 63])
716 } else {
717 '='
718 });
719 }
720 out
721}
722
723fn command_error<E: Serialize>(message: &str, typed: &E) -> String {
725 let details = serde_json::to_value(typed).ok();
726 error(BridgeErrorCode::CommandFailed, message, details)
727}
728
729fn decode_profile_document(
734 inner: &Value,
735 command: &'static str,
736) -> Result<crate::profile::ProfileData, String> {
737 if inner.is_null() {
738 return Err(error(
739 BridgeErrorCode::InvalidRequest,
740 format!("'{command}' requires a request payload (a ProfileData JSON document)"),
741 None,
742 ));
743 }
744 serde_json::from_value(inner.clone()).map_err(|err| {
745 error(
746 BridgeErrorCode::InvalidRequest,
747 format!("{command} request is not a ProfileData document: {err}"),
748 None,
749 )
750 })
751}
752
753fn run_profile_validate(inner: &Value) -> String {
760 let profile = match decode_profile_document(inner, "profile.validate") {
761 Ok(profile) => profile,
762 Err(envelope) => return envelope,
763 };
764 let warnings = profile.validation_warnings();
765 match serde_json::to_value(&profile) {
766 Ok(normalized) => success(
767 "profile.validate",
768 json!({
769 "valid": warnings.is_empty(),
770 "warnings": warnings,
771 "normalized": normalized,
772 }),
773 ),
774 Err(err) => error(
775 BridgeErrorCode::InternalError,
776 format!("failed to serialize normalized profile: {err}"),
777 None,
778 ),
779 }
780}
781
782fn run_profile_normalize(inner: &Value) -> String {
788 let profile = match decode_profile_document(inner, "profile.normalize") {
789 Ok(profile) => profile,
790 Err(envelope) => return envelope,
791 };
792 match serde_json::to_value(&profile) {
793 Ok(normalized) => success("profile.normalize", json!({ "profile": normalized })),
794 Err(err) => error(
795 BridgeErrorCode::InternalError,
796 format!("failed to serialize normalized profile: {err}"),
797 None,
798 ),
799 }
800}
801
802#[cfg(not(target_arch = "wasm32"))]
804#[derive(Debug, Deserialize)]
805#[serde(deny_unknown_fields)]
806struct Bc5dInfoRequest {
807 path: String,
808}
809
810#[cfg(not(target_arch = "wasm32"))]
830fn run_bc5d_info(inner: &Value) -> String {
831 if inner.is_null() {
832 return error(
833 BridgeErrorCode::InvalidRequest,
834 "'bc5d.info' requires a request payload ({\"path\": ...})",
835 None,
836 );
837 }
838 let request: Bc5dInfoRequest = match serde_json::from_value(inner.clone()) {
839 Ok(request) => request,
840 Err(err) => {
841 return error(
842 BridgeErrorCode::InvalidRequest,
843 format!("bc5d.info request rejected: {err}"),
844 None,
845 )
846 }
847 };
848
849 let table =
850 match crate::bc_table_5d::path_cache::load_verified(std::path::Path::new(&request.path)) {
851 Ok(table) => table,
852 Err(err) => {
853 return error(
854 BridgeErrorCode::CommandFailed,
855 format!("bc5d.info: not a usable BC5D table: {err}"),
856 None,
857 )
858 }
859 };
860
861 let (weight, bc, muzzle_vel, current_vel, drag_types) = table.bin_counts();
862 let (weight_lo, weight_hi) = table.weight_range();
863 let (vel_lo, vel_hi) = table.velocity_range();
864 success(
865 "bc5d.info",
866 json!({
867 "valid": true,
870 "crc_ok": true,
871 "format_version": table.version(),
872 "caliber": table.caliber(),
873 "caliber_key": table.caliber_key(),
875 "api_version": table.api_version(),
876 "generated_timestamp": table.timestamp(),
877 "bins": {
879 "weight": weight,
880 "bc": bc,
881 "muzzle_velocity": muzzle_vel,
882 "current_velocity": current_vel,
883 "drag_types": drag_types,
884 },
885 "total_cells": table.total_cells(),
886 "weight_range_grains": [weight_lo, weight_hi],
887 "velocity_range_fps": [vel_lo, vel_hi],
888 }),
889 )
890}
891
892#[cfg(feature = "profile-import")]
896pub const MAX_A7P_DECODED_BYTES: usize = 1024 * 1024;
897
898#[cfg(feature = "profile-import")]
904#[derive(Debug, Deserialize)]
905#[serde(deny_unknown_fields)]
906struct ProfileImportA7pRequest {
907 a7p_base64: String,
908 #[serde(default)]
909 zero_click: Option<String>,
910 #[serde(default)]
911 strict: bool,
912}
913
914#[cfg(feature = "profile-import")]
924fn run_profile_import_a7p(inner: &Value) -> String {
925 use crate::profile_import::{map_a7p_to_profile, parse_a7p, EnvelopeStatus};
926
927 if inner.is_null() {
928 return error(
929 BridgeErrorCode::InvalidRequest,
930 "'profile.import_a7p' requires a request payload ({\"a7p_base64\": ...})",
931 None,
932 );
933 }
934 let request: ProfileImportA7pRequest = match serde_json::from_value(inner.clone()) {
935 Ok(request) => request,
936 Err(err) => {
937 return error(
938 BridgeErrorCode::InvalidRequest,
939 format!("profile.import_a7p request rejected: {err}"),
940 None,
941 )
942 }
943 };
944
945 let zero_click = match request.zero_click.as_deref() {
946 Some(raw) => match crate::adjustment::parse_click_value(raw) {
947 Ok(click) => Some(click),
948 Err(err) => {
949 return error(
950 BridgeErrorCode::InvalidRequest,
951 format!("profile.import_a7p zero_click: {err}"),
952 None,
953 )
954 }
955 },
956 None => None,
957 };
958
959 let bytes = match decode_base64(&request.a7p_base64) {
960 Ok(bytes) => bytes,
961 Err(err) => {
962 return error(
963 BridgeErrorCode::InvalidRequest,
964 format!("profile.import_a7p a7p_base64: {err}"),
965 None,
966 )
967 }
968 };
969 if bytes.len() > MAX_A7P_DECODED_BYTES {
970 return error(
971 BridgeErrorCode::ResourceLimit,
972 format!(
973 "decoded .a7p payload is {} bytes; the limit is {MAX_A7P_DECODED_BYTES}",
974 bytes.len()
975 ),
976 None,
977 );
978 }
979
980 let doc = match parse_a7p(&bytes) {
981 Ok(doc) => doc,
982 Err(err) => {
983 return error(
984 BridgeErrorCode::CommandFailed,
985 format!("not a usable .a7p file: {err}"),
986 None,
987 )
988 }
989 };
990 if request.strict {
993 if let EnvelopeStatus::Mismatch { expected, actual } = &doc.envelope {
994 return error(
995 BridgeErrorCode::CommandFailed,
996 format!(
997 "checksum mismatch (file says {expected}, payload hashes to {actual}) — refusing under strict"
998 ),
999 None,
1000 );
1001 }
1002 }
1003
1004 let outcome = match map_a7p_to_profile(&doc, None, zero_click) {
1005 Ok(outcome) => outcome,
1006 Err(err) => return error(BridgeErrorCode::CommandFailed, err, None),
1007 };
1008 let unknown_fields: Vec<Value> = doc
1009 .unknown_fields
1010 .iter()
1011 .map(|u| json!({ "context": u.context, "number": u.number }))
1012 .collect();
1013 match serde_json::to_value(&outcome.profile) {
1014 Ok(profile) => success(
1015 "profile.import_a7p",
1016 json!({
1017 "profile": profile,
1018 "warnings": outcome.report.warnings,
1019 "mapped": outcome.report.mapped,
1020 "unmapped": outcome.report.unmapped,
1021 "unknown_fields": unknown_fields,
1022 }),
1023 ),
1024 Err(err) => error(
1025 BridgeErrorCode::InternalError,
1026 format!("failed to serialize imported profile: {err}"),
1027 None,
1028 ),
1029 }
1030}
1031
1032#[cfg(feature = "profile-import")]
1040fn decode_base64(input: &str) -> Result<Vec<u8>, String> {
1041 fn sextet(c: u8) -> Result<u32, String> {
1042 match c {
1043 b'A'..=b'Z' => Ok(u32::from(c - b'A')),
1044 b'a'..=b'z' => Ok(u32::from(c - b'a') + 26),
1045 b'0'..=b'9' => Ok(u32::from(c - b'0') + 52),
1046 b'+' => Ok(62),
1047 b'/' => Ok(63),
1048 _ => Err(format!("invalid base64 character {:?}", char::from(c))),
1049 }
1050 }
1051 let bytes = input.as_bytes();
1052 let data = match bytes {
1053 [rest @ .., b'=', b'='] => rest,
1054 [rest @ .., b'='] => rest,
1055 _ => bytes,
1056 };
1057 if data.contains(&b'=') {
1058 return Err("'=' is only valid as trailing padding".to_string());
1059 }
1060 if data.len() % 4 == 1 {
1061 return Err("base64 text has an impossible length (4n+1 data characters)".to_string());
1062 }
1063 let mut out = Vec::with_capacity(data.len() / 4 * 3 + 2);
1064 let mut acc: u32 = 0;
1065 let mut bits: u32 = 0;
1066 for &c in data {
1067 acc = (acc << 6) | sextet(c)?;
1068 bits += 6;
1069 if bits >= 8 {
1070 bits -= 8;
1071 out.push((acc >> bits) as u8);
1072 }
1073 }
1074 Ok(out)
1075}
1076
1077#[derive(Debug, Deserialize)]
1087#[serde(tag = "kind", rename_all = "snake_case")]
1088enum ReticleGenerator {
1089 MilGrid { spacing_mil: f64, extent_mil: f64 },
1091 Tree {
1093 rows: usize,
1094 row_spacing_mil: f64,
1095 spread_step_mil: f64,
1096 },
1097 Bdc { drops: Vec<(f64, f64)> },
1102}
1103
1104#[derive(Debug, Default, Deserialize)]
1112#[serde(deny_unknown_fields)]
1113struct ReticlePlaneOverride {
1114 #[serde(default)]
1115 focal_plane: Option<crate::reticle::FocalPlane>,
1116 #[serde(default)]
1117 reference_magnification: Option<f64>,
1118}
1119
1120fn resolve_reticle(
1124 command: &'static str,
1125 reticle: Option<crate::reticle::ReticleDescription>,
1126 generator: Option<ReticleGenerator>,
1127 catalog: Option<String>,
1128 plane: &ReticlePlaneOverride,
1129) -> Result<crate::reticle::ReticleDescription, String> {
1130 use crate::reticle::ReticleDescription;
1131
1132 let named = [reticle.is_some(), generator.is_some(), catalog.is_some()]
1135 .iter()
1136 .filter(|named| **named)
1137 .count();
1138 if named > 1 {
1139 return Err(error(
1140 BridgeErrorCode::InvalidRequest,
1141 format!(
1142 "{command} takes exactly one of 'reticle', 'generator' or 'catalog' — \
1143 supplying more than one is a caller bug rather than a preference"
1144 ),
1145 None,
1146 ));
1147 }
1148
1149 let reticle = match (reticle, catalog) {
1151 (reticle, None) => reticle,
1152 (_, Some(id)) => match crate::reticle_catalog::by_id(&id) {
1153 Some(Ok(built)) => Some(built),
1154 Some(Err(err)) => return Err(reticle_error_envelope(command, &err)),
1155 None => {
1158 return Err(error(
1159 BridgeErrorCode::InvalidRequest,
1160 format!(
1161 "{command}: no catalog reticle with id {id:?}; \
1162 'reticle.catalog' lists what this build has"
1163 ),
1164 None,
1165 ))
1166 }
1167 },
1168 };
1169
1170 let mut described = match (reticle, generator) {
1171 (Some(_), Some(_)) => unreachable!("the count above rejected more than one source"),
1172 (None, None) => {
1173 return Err(error(
1174 BridgeErrorCode::InvalidRequest,
1175 format!(
1176 "{command} requires one of 'reticle' (a full description), \
1177 'generator' or 'catalog'"
1178 ),
1179 None,
1180 ))
1181 }
1182 (Some(reticle), None) => {
1183 if plane.focal_plane.is_some() || plane.reference_magnification.is_some() {
1184 return Err(error(
1185 BridgeErrorCode::InvalidRequest,
1186 format!(
1187 "{command}: 'focal_plane' and 'reference_magnification' override a \
1188 GENERATED reticle; a 'reticle' or 'catalog' one already carries its own"
1189 ),
1190 None,
1191 ));
1192 }
1193 reticle
1194 }
1195 (None, Some(generator)) => {
1196 let built = match generator {
1197 ReticleGenerator::MilGrid {
1198 spacing_mil,
1199 extent_mil,
1200 } => ReticleDescription::mil_grid(spacing_mil, extent_mil),
1201 ReticleGenerator::Tree {
1202 rows,
1203 row_spacing_mil,
1204 spread_step_mil,
1205 } => ReticleDescription::tree(rows, row_spacing_mil, spread_step_mil),
1206 ReticleGenerator::Bdc { drops } => ReticleDescription::bdc_from_drops(&drops),
1207 };
1208 match built {
1209 Ok(built) => built,
1210 Err(err) => return Err(reticle_error_envelope(command, &err)),
1211 }
1212 }
1213 };
1214
1215 if let Some(focal_plane) = plane.focal_plane {
1216 described.focal_plane = focal_plane;
1217 }
1218 if let Some(reference_magnification) = plane.reference_magnification {
1219 described.reference_magnification = reference_magnification;
1220 }
1221 Ok(described)
1222}
1223
1224fn reticle_error_envelope(command: &'static str, err: &crate::reticle::ReticleError) -> String {
1232 use crate::reticle::ReticleError;
1233
1234 let mut details = json!({ "reason": match err {
1235 ReticleError::NonPositiveMagnification { .. } => "non_positive_magnification",
1236 ReticleError::NonPositiveReferenceMagnification { .. } => {
1237 "non_positive_reference_magnification"
1238 }
1239 ReticleError::NoMarks => "no_marks",
1240 ReticleError::TooManyMarks { .. } => "too_many_marks",
1241 ReticleError::NonFiniteMark { .. } => "non_finite_mark",
1242 ReticleError::NonFiniteHold { .. } => "non_finite_hold",
1243 ReticleError::InvalidGeneratorParameter { .. } => "invalid_generator_parameter",
1244 ReticleError::InvalidSpec(_) => "invalid_spec",
1245 }});
1246
1247 match err {
1250 ReticleError::NonPositiveMagnification { magnification } => {
1251 details["magnification"] = json!(magnification);
1252 }
1253 ReticleError::NonPositiveReferenceMagnification {
1254 reference_magnification,
1255 } => {
1256 details["reference_magnification"] = json!(reference_magnification);
1257 }
1258 ReticleError::TooManyMarks { count, max } => {
1259 details["count"] = json!(count);
1260 details["max"] = json!(max);
1261 }
1262 ReticleError::NonFiniteMark { index } => {
1263 details["index"] = json!(index);
1264 }
1265 ReticleError::InvalidGeneratorParameter {
1266 parameter,
1267 value,
1268 rule,
1269 } => {
1270 details["parameter"] = json!(parameter);
1271 details["value"] = json!(value);
1272 details["rule"] = json!(rule);
1273 }
1274 ReticleError::NoMarks
1275 | ReticleError::NonFiniteHold { .. }
1276 | ReticleError::InvalidSpec(_) => {}
1277 }
1278 details["command"] = json!(command);
1279
1280 error(
1281 BridgeErrorCode::CommandFailed,
1282 err.to_string(),
1283 Some(details),
1284 )
1285}
1286
1287fn nearest_mark_value(
1295 reticle: &crate::reticle::ReticleDescription,
1296 hold: &crate::reticle::ReticleHold,
1297) -> Value {
1298 match hold
1299 .nearest_mark
1300 .and_then(|index| reticle.marks.get(index).map(|mark| (index, mark)))
1301 {
1302 Some((index, mark)) => json!({
1303 "index": index,
1304 "kind": mark.kind.as_str(),
1305 "label": mark.label,
1306 "nominal": { "down_mil": mark.down_mil, "right_mil": mark.right_mil },
1307 "true_angular": {
1308 "down_mil": mark.down_mil * hold.mark_scale,
1309 "right_mil": mark.right_mil * hold.mark_scale,
1310 },
1311 }),
1312 None => Value::Null,
1313 }
1314}
1315
1316fn run_reticle_catalog() -> String {
1327 let reticles: Vec<Value> = crate::reticle_catalog::catalog()
1328 .into_iter()
1329 .map(|entry| {
1330 let mark_count = crate::reticle_catalog::by_id(entry.id)
1334 .and_then(|built| built.ok())
1335 .map(|reticle| reticle.marks.len());
1336 json!({
1337 "id": entry.id,
1338 "display_name": entry.display_name,
1339 "source": entry.source,
1340 "notes": entry.notes,
1341 "mark_count": mark_count,
1342 })
1343 })
1344 .collect();
1345 success("reticle.catalog", json!({ "reticles": reticles }))
1346}
1347
1348#[derive(Debug, Deserialize)]
1349#[serde(deny_unknown_fields)]
1350struct ReticleDescribeRequest {
1351 #[serde(default)]
1352 reticle: Option<crate::reticle::ReticleDescription>,
1353 #[serde(default)]
1354 generator: Option<ReticleGenerator>,
1355 #[serde(default)]
1358 catalog: Option<String>,
1359 #[serde(default)]
1360 focal_plane: Option<crate::reticle::FocalPlane>,
1361 #[serde(default)]
1362 reference_magnification: Option<f64>,
1363 #[serde(default)]
1366 magnification: Option<f64>,
1367}
1368
1369fn run_reticle_describe(inner: &Value) -> String {
1379 if inner.is_null() {
1380 return error(
1381 BridgeErrorCode::InvalidRequest,
1382 "'reticle.describe' requires a request payload ({\"reticle\": ...} or {\"generator\": ...})",
1383 None,
1384 );
1385 }
1386 let request: ReticleDescribeRequest = match serde_json::from_value(inner.clone()) {
1387 Ok(request) => request,
1388 Err(err) => {
1389 return error(
1390 BridgeErrorCode::InvalidRequest,
1391 format!("reticle.describe request rejected: {err}"),
1392 None,
1393 )
1394 }
1395 };
1396
1397 let plane = ReticlePlaneOverride {
1398 focal_plane: request.focal_plane,
1399 reference_magnification: request.reference_magnification,
1400 };
1401 let reticle = match resolve_reticle(
1402 "reticle.describe",
1403 request.reticle,
1404 request.generator,
1405 request.catalog,
1406 &plane,
1407 ) {
1408 Ok(reticle) => reticle,
1409 Err(envelope) => return envelope,
1410 };
1411
1412 if let Err(err) = reticle.validate() {
1415 return reticle_error_envelope("reticle.describe", &err);
1416 }
1417
1418 let scaled = match request.magnification {
1419 Some(magnification) => match reticle.scaled_marks(magnification) {
1420 Ok(marks) => json!({
1421 "magnification": magnification,
1422 "mark_scale": reticle.mark_scale(magnification),
1423 "marks": marks
1424 .iter()
1425 .map(|mark| json!({
1426 "down_mil": mark.down_mil,
1427 "right_mil": mark.right_mil,
1428 }))
1429 .collect::<Vec<_>>(),
1430 }),
1431 Err(err) => return reticle_error_envelope("reticle.describe", &err),
1432 },
1433 None => Value::Null,
1434 };
1435
1436 match serde_json::to_value(&reticle) {
1437 Ok(described) => success(
1438 "reticle.describe",
1439 json!({
1440 "reticle": described,
1441 "focal_plane": reticle.focal_plane.label(),
1442 "magnification_dependent": reticle.focal_plane.is_magnification_dependent(),
1443 "mark_count": reticle.marks.len(),
1444 "scaled": scaled,
1445 }),
1446 ),
1447 Err(err) => error(
1448 BridgeErrorCode::InternalError,
1449 format!("failed to serialize reticle description: {err}"),
1450 None,
1451 ),
1452 }
1453}
1454
1455#[derive(Debug, Deserialize)]
1456#[serde(deny_unknown_fields)]
1457struct ReticleHoldRequest {
1458 #[serde(default)]
1459 reticle: Option<crate::reticle::ReticleDescription>,
1460 #[serde(default)]
1461 generator: Option<ReticleGenerator>,
1462 #[serde(default)]
1465 catalog: Option<String>,
1466 #[serde(default)]
1467 focal_plane: Option<crate::reticle::FocalPlane>,
1468 #[serde(default)]
1469 reference_magnification: Option<f64>,
1470 drop_mil: f64,
1473 #[serde(default)]
1475 wind_mil: f64,
1476 magnification: f64,
1480}
1481
1482fn run_reticle_hold(inner: &Value) -> String {
1498 if inner.is_null() {
1499 return error(
1500 BridgeErrorCode::InvalidRequest,
1501 "'reticle.hold' requires a request payload ({reticle|generator, drop_mil, magnification})",
1502 None,
1503 );
1504 }
1505 let request: ReticleHoldRequest = match serde_json::from_value(inner.clone()) {
1506 Ok(request) => request,
1507 Err(err) => {
1508 return error(
1509 BridgeErrorCode::InvalidRequest,
1510 format!("reticle.hold request rejected: {err}"),
1511 None,
1512 )
1513 }
1514 };
1515
1516 let plane = ReticlePlaneOverride {
1517 focal_plane: request.focal_plane,
1518 reference_magnification: request.reference_magnification,
1519 };
1520 let reticle = match resolve_reticle(
1521 "reticle.hold",
1522 request.reticle,
1523 request.generator,
1524 request.catalog,
1525 &plane,
1526 ) {
1527 Ok(reticle) => reticle,
1528 Err(envelope) => return envelope,
1529 };
1530
1531 let hold = match crate::reticle::hold_point_in_reticle(
1532 request.drop_mil,
1533 request.wind_mil,
1534 request.magnification,
1535 &reticle,
1536 ) {
1537 Ok(hold) => hold,
1538 Err(err) => return reticle_error_envelope("reticle.hold", &err),
1539 };
1540
1541 let nearest = nearest_mark_value(&reticle, &hold);
1542 match serde_json::to_value(&hold) {
1543 Ok(hold_value) => success(
1544 "reticle.hold",
1545 json!({
1546 "hold": hold_value,
1547 "nearest_mark": nearest,
1548 }),
1549 ),
1550 Err(err) => error(
1551 BridgeErrorCode::InternalError,
1552 format!("failed to serialize reticle hold: {err}"),
1553 None,
1554 ),
1555 }
1556}
1557
1558pub const MAX_RETICLE_HOLDS: usize = 4096;
1563
1564#[derive(Debug, Deserialize)]
1565#[serde(deny_unknown_fields)]
1566struct ReticleHoldsEntry {
1567 drop_mil: f64,
1568 #[serde(default)]
1569 wind_mil: f64,
1570}
1571
1572#[derive(Debug, Deserialize)]
1573#[serde(deny_unknown_fields)]
1574struct ReticleHoldsRequest {
1575 #[serde(default)]
1576 reticle: Option<crate::reticle::ReticleDescription>,
1577 #[serde(default)]
1578 generator: Option<ReticleGenerator>,
1579 #[serde(default)]
1580 catalog: Option<String>,
1581 #[serde(default)]
1582 focal_plane: Option<crate::reticle::FocalPlane>,
1583 #[serde(default)]
1584 reference_magnification: Option<f64>,
1585 magnification: f64,
1586 holds: Vec<ReticleHoldsEntry>,
1589}
1590
1591fn run_reticle_holds(inner: &Value) -> String {
1608 if inner.is_null() {
1609 return error(
1610 BridgeErrorCode::InvalidRequest,
1611 "'reticle.holds' requires a request payload ({reticle|generator|catalog, magnification, holds})",
1612 None,
1613 );
1614 }
1615 let request: ReticleHoldsRequest = match serde_json::from_value(inner.clone()) {
1616 Ok(request) => request,
1617 Err(err) => {
1618 return error(
1619 BridgeErrorCode::InvalidRequest,
1620 format!("reticle.holds request rejected: {err}"),
1621 None,
1622 )
1623 }
1624 };
1625
1626 if request.holds.len() > MAX_RETICLE_HOLDS {
1627 return error(
1628 BridgeErrorCode::ResourceLimit,
1629 format!(
1630 "reticle.holds was given {} holds; the limit is {MAX_RETICLE_HOLDS}",
1631 request.holds.len()
1632 ),
1633 None,
1634 );
1635 }
1636
1637 let plane = ReticlePlaneOverride {
1638 focal_plane: request.focal_plane,
1639 reference_magnification: request.reference_magnification,
1640 };
1641 let reticle = match resolve_reticle(
1642 "reticle.holds",
1643 request.reticle,
1644 request.generator,
1645 request.catalog,
1646 &plane,
1647 ) {
1648 Ok(reticle) => reticle,
1649 Err(envelope) => return envelope,
1650 };
1651
1652 let mut placed = Vec::with_capacity(request.holds.len());
1653 let mut scale = 1.0;
1654 for entry in &request.holds {
1655 let hold = match crate::reticle::hold_point_in_reticle(
1656 entry.drop_mil,
1657 entry.wind_mil,
1658 request.magnification,
1659 &reticle,
1660 ) {
1661 Ok(hold) => hold,
1662 Err(err) => return reticle_error_envelope("reticle.holds", &err),
1669 };
1670 scale = hold.mark_scale;
1671 let nearest = nearest_mark_value(&reticle, &hold);
1672 match serde_json::to_value(&hold) {
1673 Ok(mut value) => {
1674 if let Some(object) = value.as_object_mut() {
1676 object.remove("mark_scale");
1677 }
1678 placed.push(json!({ "hold": value, "nearest_mark": nearest }));
1679 }
1680 Err(err) => {
1681 return error(
1682 BridgeErrorCode::InternalError,
1683 format!("failed to serialize reticle hold: {err}"),
1684 None,
1685 )
1686 }
1687 }
1688 }
1689
1690 success(
1691 "reticle.holds",
1692 json!({ "mark_scale": scale, "holds": placed }),
1693 )
1694}
1695
1696pub const MAX_RETICLE_DOCUMENT_BYTES: usize = 512 * 1024;
1704
1705#[derive(Debug, Deserialize)]
1706#[serde(rename_all = "snake_case")]
1707enum ReticleDocumentFormat {
1708 Ventum,
1710 ReticleXml,
1712}
1713
1714#[derive(Debug, Deserialize)]
1715#[serde(deny_unknown_fields)]
1716struct ReticleImportRequest {
1717 format: ReticleDocumentFormat,
1718 document: String,
1720}
1721
1722fn run_reticle_import(inner: &Value) -> String {
1731 if inner.is_null() {
1732 return error(
1733 BridgeErrorCode::InvalidRequest,
1734 "'reticle.import' requires a request payload ({\"format\": ..., \"document\": ...})",
1735 None,
1736 );
1737 }
1738 let request: ReticleImportRequest = match serde_json::from_value(inner.clone()) {
1739 Ok(request) => request,
1740 Err(err) => {
1741 return error(
1742 BridgeErrorCode::InvalidRequest,
1743 format!("reticle.import request rejected: {err}"),
1744 None,
1745 )
1746 }
1747 };
1748
1749 if request.document.len() > MAX_RETICLE_DOCUMENT_BYTES {
1750 return error(
1751 BridgeErrorCode::ResourceLimit,
1752 format!(
1753 "reticle document is {} bytes; the limit is {MAX_RETICLE_DOCUMENT_BYTES}",
1754 request.document.len()
1755 ),
1756 None,
1757 );
1758 }
1759
1760 let (reticle, report, format) = match request.format {
1761 ReticleDocumentFormat::Ventum => {
1762 match crate::reticle_import::import_ventum_reticle_with_report(&request.document) {
1763 Ok((reticle, report)) => (
1764 reticle,
1765 json!({
1766 "dropped_elements": report.dropped_elements,
1767 "dropped_element_types": report
1768 .dropped_element_types
1769 .iter()
1770 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1771 .collect::<Vec<_>>(),
1772 "arcs": report
1777 .arcs
1778 .iter()
1779 .map(|arc| json!({
1780 "center": { "down_mil": arc.center.down_mil, "right_mil": arc.center.right_mil },
1781 "radius_mil": arc.radius_mil,
1782 "start_degrees": arc.start_degrees,
1783 "end_degrees": arc.end_degrees,
1784 "sweep_degrees": arc.sweep_degrees,
1785 "start_tip": { "down_mil": arc.start_tip.down_mil, "right_mil": arc.start_tip.right_mil },
1786 "apex": { "down_mil": arc.apex.down_mil, "right_mil": arc.apex.right_mil },
1787 "end_tip": { "down_mil": arc.end_tip.down_mil, "right_mil": arc.end_tip.right_mil },
1788 }))
1789 .collect::<Vec<_>>(),
1790 "arcs_unresolved": report.arcs_unresolved,
1795 "circle_repeats_unreadable": report.circle_repeats_unreadable,
1796 }),
1797 "ventum",
1798 ),
1799 Err(err) => return reticle_error_envelope("reticle.import", &err),
1800 }
1801 }
1802 ReticleDocumentFormat::ReticleXml => {
1803 match crate::reticle_document_import::import_reticle_document_with_report(
1804 &request.document,
1805 ) {
1806 Ok((reticle, report)) => (
1807 reticle,
1808 json!({
1809 "drawing_elements_dropped": report.drawing_elements_dropped,
1810 "dropped_element_tags": report
1811 .dropped_element_tags
1812 .iter()
1813 .map(|(tag, count)| json!({ "tag": tag, "count": count }))
1814 .collect::<Vec<_>>(),
1815 "canvas_mil": report
1816 .canvas_mil
1817 .map(|(x, y)| json!({ "size_x_mil": x, "size_y_mil": y })),
1818 "zero_in_canvas_mil": report
1819 .zero_in_canvas_mil
1820 .map(|(x, y)| json!({ "zero_x_mil": x, "zero_y_mil": y })),
1821 }),
1822 "reticle_xml",
1823 ),
1824 Err(err) => return reticle_error_envelope("reticle.import", &err),
1825 }
1826 }
1827 };
1828
1829 match serde_json::to_value(&reticle) {
1830 Ok(imported) => success(
1831 "reticle.import",
1832 json!({
1833 "reticle": imported,
1834 "format": format,
1835 "mark_count": reticle.marks.len(),
1836 "report": report,
1837 }),
1838 ),
1839 Err(err) => error(
1840 BridgeErrorCode::InternalError,
1841 format!("failed to serialize imported reticle: {err}"),
1842 None,
1843 ),
1844 }
1845}
1846
1847#[cfg(test)]
1848mod tests {
1849 use super::*;
1850
1851 fn call(value: Value) -> Value {
1852 let raw = bridge_call(&value.to_string());
1853 serde_json::from_str(&raw).expect("bridge output must be valid JSON")
1854 }
1855
1856 #[test]
1857 fn capabilities_reports_commands_and_versions() {
1858 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1859 assert_eq!(out["ok"], true);
1860 assert_eq!(out["api_version"], 1);
1861 assert_eq!(out["result"]["engine_version"], ENGINE_VERSION);
1862 let commands: Vec<String> =
1863 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1864 assert!(commands.contains(&"solve".to_string()));
1865 assert!(commands.contains(&"meta.capabilities".to_string()));
1866 }
1867
1868 #[test]
1869 fn invalid_json_is_an_envelope_not_a_panic() {
1870 let out: Value = serde_json::from_str(&bridge_call("{not json")).unwrap();
1871 assert_eq!(out["ok"], false);
1872 assert_eq!(out["error"]["code"], "invalid_json");
1873 }
1874
1875 #[test]
1876 fn unknown_envelope_field_is_rejected() {
1877 let out = call(json!({"api_version": 1, "command": "meta.version", "extra": 1}));
1878 assert_eq!(out["ok"], false);
1879 assert_eq!(out["error"]["code"], "invalid_json");
1880 }
1881
1882 #[test]
1883 fn unknown_command_lists_supported_ones() {
1884 let out = call(json!({"api_version": 1, "command": "card.semaphore"}));
1887 assert_eq!(out["error"]["code"], "unknown_command");
1888 assert!(out["error"]["message"]
1889 .as_str()
1890 .unwrap()
1891 .contains("meta.capabilities"));
1892 }
1893
1894 #[test]
1895 fn wrong_api_version_is_rejected() {
1896 let out = call(json!({"api_version": 99, "command": "meta.version"}));
1897 assert_eq!(out["error"]["code"], "unsupported_api_version");
1898 }
1899
1900 #[test]
1901 fn oversize_request_is_a_resource_limit() {
1902 let big = format!(
1903 r#"{{"api_version":1,"command":"meta.version","request":"{}"}}"#,
1904 "x".repeat(MAX_REQUEST_BYTES)
1905 );
1906 let out: Value = serde_json::from_str(&bridge_call(&big)).unwrap();
1907 assert_eq!(out["error"]["code"], "resource_limit");
1908 }
1909
1910 #[test]
1911 fn solve_without_payload_is_invalid_request() {
1912 let out = call(json!({"api_version": 1, "command": "solve"}));
1913 assert_eq!(out["error"]["code"], "invalid_request");
1914 }
1915
1916 #[test]
1917 fn profile_commands_without_payload_are_invalid_requests() {
1918 for command in ["profile.validate", "profile.normalize"] {
1919 let out = call(json!({"api_version": 1, "command": command}));
1920 assert_eq!(out["error"]["code"], "invalid_request", "{command}: {out}");
1921 assert!(
1922 out["error"]["message"]
1923 .as_str()
1924 .unwrap()
1925 .contains("ProfileData"),
1926 "{command}: {out}"
1927 );
1928 }
1929 }
1930
1931 #[test]
1932 fn capabilities_lists_profile_commands_and_gates_import_on_the_feature() {
1933 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
1934 let commands: Vec<String> =
1935 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
1936 assert!(commands.contains(&"profile.validate".to_string()));
1937 assert!(commands.contains(&"profile.normalize".to_string()));
1938 assert_eq!(
1939 commands.contains(&"profile.import_a7p".to_string()),
1940 cfg!(feature = "profile-import"),
1941 "profile.import_a7p must be listed exactly when compiled in"
1942 );
1943 assert_eq!(
1944 commands.contains(&"bc5d.info".to_string()),
1945 cfg!(not(target_arch = "wasm32")),
1946 "bc5d.info must be listed exactly when the build has filesystem access"
1947 );
1948 }
1949
1950 #[cfg(not(target_arch = "wasm32"))]
1951 #[test]
1952 fn bc5d_info_without_payload_or_with_missing_file_fails_cleanly() {
1953 let out = call(json!({"api_version": 1, "command": "bc5d.info"}));
1954 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
1955
1956 let out = call(json!({
1957 "api_version": 1,
1958 "command": "bc5d.info",
1959 "request": {"path": "/nonexistent/bc5d_308.bin"}
1960 }));
1961 assert_eq!(out["error"]["code"], "command_failed", "{out}");
1962 assert!(
1963 out["error"]["message"]
1964 .as_str()
1965 .unwrap()
1966 .contains("not a usable BC5D table"),
1967 "{out}"
1968 );
1969 }
1970
1971 #[cfg(feature = "profile-import")]
1972 #[test]
1973 fn base64_decoder_round_trips_and_rejects_garbage() {
1974 for (text, bytes) in [
1976 ("", &b""[..]),
1977 ("Zg==", b"f"),
1978 ("Zm8=", b"fo"),
1979 ("Zm9v", b"foo"),
1980 ("Zm9vYg==", b"foob"),
1981 ("Zm9vYmE=", b"fooba"),
1982 ("Zm9vYmFy", b"foobar"),
1983 ] {
1984 assert_eq!(decode_base64(text).unwrap(), bytes, "{text}");
1985 }
1986 assert!(decode_base64("Zm9v\n").is_err(), "whitespace is rejected");
1987 assert!(decode_base64("Zg=X").is_err(), "inner padding is rejected");
1988 assert!(decode_base64("Z").is_err(), "4n+1 length is rejected");
1989 assert!(
1990 decode_base64("Zm9v!").is_err(),
1991 "non-alphabet byte is rejected"
1992 );
1993 }
1994
1995 #[test]
1999 fn capabilities_gates_card_pdf_on_the_pdf_feature() {
2000 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2001 let commands: Vec<String> =
2002 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2003 assert_eq!(
2004 commands.contains(&"card.pdf".to_string()),
2005 cfg!(feature = "pdf"),
2006 "card.pdf must be listed exactly when compiled in: {out}"
2007 );
2008 let features: Vec<String> =
2009 serde_json::from_value(out["result"]["features"].clone()).unwrap();
2010 assert_eq!(
2011 features.contains(&"pdf".to_string()),
2012 cfg!(feature = "pdf"),
2013 "the command list and the feature list must agree: {out}"
2014 );
2015 }
2016
2017 #[cfg(not(feature = "pdf"))]
2018 #[test]
2019 fn card_pdf_is_an_unknown_command_without_the_pdf_feature() {
2020 let out = call(json!({
2021 "api_version": 1,
2022 "command": "card.pdf",
2023 "request": {
2024 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2025 "mass": 175.0, "diameter": 0.308,
2026 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0
2027 }
2028 }));
2029 assert_eq!(out["error"]["code"], "unknown_command", "{out}");
2030 }
2031
2032 #[test]
2036 fn the_pdf_presentation_block_is_accepted_by_the_on_screen_card_in_every_build() {
2037 let out = call(json!({
2038 "api_version": 1,
2039 "command": "card.range_table",
2040 "request": {
2041 "muzzle_velocity": 2600.0, "ballistic_coefficient": 0.243,
2042 "mass": 175.0, "diameter": 0.308,
2043 "zero_distance": 100.0, "start": 100.0, "end": 300.0, "step": 100.0,
2044 "pdf": {"title": "Stored Card", "target_speed": 8.0, "font_preset": "large"}
2045 }
2046 }));
2047 assert_eq!(out["ok"], true, "{out}");
2048 assert_eq!(out["result"]["kind"], "range_table", "{out}");
2049 }
2050
2051 #[cfg(feature = "pdf")]
2052 #[test]
2053 fn card_pdf_without_payload_is_invalid_request() {
2054 let out = call(json!({"api_version": 1, "command": "card.pdf"}));
2055 assert_eq!(out["error"]["code"], "invalid_request", "{out}");
2056 assert!(
2057 out["error"]["message"]
2058 .as_str()
2059 .unwrap()
2060 .contains("card v1 document"),
2061 "{out}"
2062 );
2063 }
2064
2065 #[cfg(feature = "pdf")]
2069 #[test]
2070 fn pdf_output_cap_refuses_only_over_the_limit() {
2071 assert!(pdf_over_cap_error(0, 0, 0).is_none());
2072 assert!(
2073 pdf_over_cap_error(MAX_PDF_BYTES, 6, 1).is_none(),
2074 "a document exactly at the cap fits"
2075 );
2076 let envelope: Value =
2077 serde_json::from_str(&pdf_over_cap_error(MAX_PDF_BYTES + 1, 6, 1).expect("over cap"))
2078 .unwrap();
2079 assert_eq!(envelope["ok"], false);
2080 assert_eq!(envelope["error"]["code"], "resource_limit");
2081 let message = envelope["error"]["message"].as_str().unwrap();
2082 assert!(message.contains("dope card"), "{envelope}");
2083 assert!(message.contains("6 rows"), "{envelope}");
2085 assert!(message.contains("1 pages"), "{envelope}");
2086 for absent in ["coarsen", "shorten"] {
2087 assert!(!message.contains(absent), "{envelope}");
2088 }
2089 }
2090
2091 #[cfg(feature = "pdf")]
2092 #[test]
2093 fn base64_encoder_matches_the_rfc_4648_vectors() {
2094 for (bytes, text) in [
2095 (&b""[..], ""),
2096 (b"f", "Zg=="),
2097 (b"fo", "Zm8="),
2098 (b"foo", "Zm9v"),
2099 (b"foob", "Zm9vYg=="),
2100 (b"fooba", "Zm9vYmE="),
2101 (b"foobar", "Zm9vYmFy"),
2102 ] {
2103 assert_eq!(encode_base64(bytes), text, "{bytes:?}");
2104 }
2105 assert_eq!(encode_base64(&[0xff, 0xff, 0xff]), "////");
2108 assert_eq!(encode_base64(&[0x00, 0x00, 0x00]), "AAAA");
2109 assert_eq!(encode_base64(&[0xfb, 0xff, 0xbf]), "+/+/");
2110 }
2111
2112 #[cfg(all(feature = "pdf", feature = "profile-import"))]
2115 #[test]
2116 fn base64_encode_decode_round_trips_arbitrary_bytes() {
2117 for len in 0..=32usize {
2118 let bytes: Vec<u8> = (0..len)
2119 .map(|i| (i as u8).wrapping_mul(37).wrapping_add(11))
2120 .collect();
2121 let decoded = decode_base64(&encode_base64(&bytes)).expect("own output decodes");
2122 assert_eq!(decoded, bytes, "len {len}");
2123 }
2124 }
2125
2126 #[test]
2127 fn solve_with_bad_schema_carries_typed_details() {
2128 let out = call(json!({
2129 "api_version": 1,
2130 "command": "solve",
2131 "request": {"schema_version": 1, "unknown_field": true}
2132 }));
2133 assert_eq!(out["error"]["code"], "command_failed");
2134 assert_eq!(out["error"]["details"]["status"], "error");
2136 }
2137
2138 fn hash_reticle() -> Value {
2145 json!({
2146 "name": "test cross",
2147 "focal_plane": "ffp",
2148 "reference_magnification": 1.0,
2149 "marks": [
2150 {"down_mil": 0.0, "right_mil": 0.0, "kind": "center"},
2151 {"down_mil": 2.0, "right_mil": 0.0, "kind": "hash", "label": "2 mil"},
2152 {"down_mil": 4.0, "right_mil": 0.0, "kind": "hash"},
2153 {"down_mil": 0.0, "right_mil": 2.0, "kind": "hash"},
2154 ],
2155 })
2156 }
2157
2158 #[test]
2159 fn capabilities_lists_the_reticle_family() {
2160 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2163 let commands: Vec<String> =
2164 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2165 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2166 assert!(
2167 commands.contains(&command.to_string()),
2168 "{command} dispatches but meta.capabilities does not list it"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn hold_reports_the_nearest_mark_and_its_label() {
2175 let out = call(json!({
2176 "api_version": 1,
2177 "command": "reticle.hold",
2178 "request": {
2179 "reticle": hash_reticle(),
2180 "drop_mil": 2.1,
2181 "wind_mil": 0.0,
2182 "magnification": 10.0,
2183 },
2184 }));
2185 assert_eq!(out["ok"], true, "{out}");
2186 assert_eq!(out["result"]["hold"]["down_mil"], 2.1);
2189 assert_eq!(out["result"]["hold"]["right_mil"], 0.0);
2190 assert_eq!(out["result"]["hold"]["off_reticle"], false);
2191 assert_eq!(out["result"]["nearest_mark"]["index"], 1);
2192 assert_eq!(out["result"]["nearest_mark"]["label"], "2 mil");
2193 assert_eq!(out["result"]["nearest_mark"]["kind"], "hash");
2194 }
2195
2196 #[test]
2197 fn an_sfp_reticle_moves_its_marks_and_the_hold_follows() {
2198 let mut sfp = hash_reticle();
2202 sfp["focal_plane"] = json!("sfp");
2203 sfp["reference_magnification"] = json!(10.0);
2204
2205 let at_reference = call(json!({
2206 "api_version": 1, "command": "reticle.hold",
2207 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 10.0},
2208 }));
2209 assert_eq!(at_reference["result"]["hold"]["mark_scale"], 1.0);
2210 assert_eq!(at_reference["result"]["nearest_mark"]["index"], 1);
2211 assert_eq!(
2212 at_reference["result"]["nearest_mark"]["true_angular"]["down_mil"],
2213 2.0
2214 );
2215
2216 let zoomed_out = call(json!({
2219 "api_version": 1, "command": "reticle.hold",
2220 "request": {"reticle": sfp, "drop_mil": 2.0, "magnification": 5.0},
2221 }));
2222 assert_eq!(zoomed_out["result"]["hold"]["mark_scale"], 2.0);
2223 assert_eq!(
2224 zoomed_out["result"]["nearest_mark"]["true_angular"]["down_mil"], 0.0,
2225 "at 5x the nearest mark to a 2 mil hold is now CENTER, not the 2 mil hash"
2226 );
2227 assert_eq!(zoomed_out["result"]["nearest_mark"]["index"], 0);
2228 assert_eq!(
2230 zoomed_out["result"]["nearest_mark"]["nominal"]["down_mil"],
2231 0.0
2232 );
2233
2234 let on_the_scaled_hash = call(json!({
2242 "api_version": 1, "command": "reticle.hold",
2243 "request": {"reticle": sfp, "drop_mil": 4.0, "magnification": 5.0},
2244 }));
2245 assert_eq!(
2246 on_the_scaled_hash["result"]["nearest_mark"]["nominal"]["down_mil"], 2.0,
2247 "as authored, the mark is at 2 mil"
2248 );
2249 assert_eq!(
2250 on_the_scaled_hash["result"]["nearest_mark"]["true_angular"]["down_mil"], 4.0,
2251 "at 5x it subtends 4 mil, which is what the hold is measured against"
2252 );
2253 assert_eq!(
2254 on_the_scaled_hash["result"]["hold"]["nearest_mark_distance_mil"], 0.0,
2255 "the hold lands exactly on it"
2256 );
2257 }
2258
2259 #[test]
2260 fn a_hold_off_the_marked_part_says_so() {
2261 let out = call(json!({
2264 "api_version": 1, "command": "reticle.hold",
2265 "request": {"reticle": hash_reticle(), "drop_mil": 40.0, "magnification": 10.0},
2266 }));
2267 assert_eq!(out["ok"], true);
2268 assert_eq!(out["result"]["hold"]["off_reticle"], true);
2269 }
2270
2271 #[test]
2272 fn describe_builds_from_a_generator_and_reports_the_plane() {
2273 let out = call(json!({
2274 "api_version": 1, "command": "reticle.describe",
2275 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0}},
2276 }));
2277 assert_eq!(out["ok"], true, "{out}");
2278 assert_eq!(out["result"]["focal_plane"], "FFP");
2279 assert_eq!(out["result"]["magnification_dependent"], false);
2280 assert!(out["result"]["mark_count"].as_u64().unwrap() > 1);
2281 assert!(out["result"]["scaled"].is_null());
2283 }
2284
2285 #[test]
2286 fn a_generated_reticle_can_be_made_sfp() {
2287 let out = call(json!({
2291 "api_version": 1, "command": "reticle.describe",
2292 "request": {
2293 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2294 "focal_plane": "sfp",
2295 "reference_magnification": 12.0,
2296 "magnification": 6.0,
2297 },
2298 }));
2299 assert_eq!(out["ok"], true, "{out}");
2300 assert_eq!(out["result"]["focal_plane"], "SFP");
2301 assert_eq!(out["result"]["magnification_dependent"], true);
2302 assert_eq!(out["result"]["scaled"]["mark_scale"], 2.0);
2303 }
2304
2305 #[test]
2306 fn a_bdc_ladder_carries_its_range_labels() {
2307 let out = call(json!({
2308 "api_version": 1, "command": "reticle.describe",
2309 "request": {"generator": {"kind": "bdc", "drops": [[300.0, 1.2], [400.0, 2.4]]}},
2310 }));
2311 assert_eq!(out["ok"], true, "{out}");
2312 let marks = out["result"]["reticle"]["marks"]
2313 .as_array()
2314 .unwrap()
2315 .clone();
2316 let labels: Vec<&str> = marks.iter().filter_map(|m| m["label"].as_str()).collect();
2317 assert_eq!(labels, vec!["300 m", "400 m"]);
2319 }
2320
2321 #[test]
2322 fn naming_a_reticle_twice_is_refused_rather_than_resolved() {
2323 let out = call(json!({
2325 "api_version": 1, "command": "reticle.hold",
2326 "request": {
2327 "reticle": hash_reticle(),
2328 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2329 "drop_mil": 1.0,
2330 "magnification": 10.0,
2331 },
2332 }));
2333 assert_eq!(out["ok"], false);
2334 assert_eq!(out["error"]["code"], "invalid_request");
2335 assert!(out["error"]["message"]
2336 .as_str()
2337 .unwrap()
2338 .contains("exactly one"));
2339 }
2340
2341 #[test]
2342 fn a_catalog_id_is_the_third_way_to_name_a_reticle() {
2343 let out = call(json!({
2344 "api_version": 1, "command": "reticle.hold",
2345 "request": {"catalog": "mil-dot", "drop_mil": 3.0, "magnification": 10.0},
2346 }));
2347 assert_eq!(out["ok"], true, "{out}");
2348 assert_eq!(out["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2350 assert_eq!(out["result"]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2351 assert_eq!(out["result"]["nearest_mark"]["label"], "3 mil down");
2352 }
2353
2354 #[test]
2355 fn an_unknown_catalog_id_is_refused_rather_than_substituted() {
2356 let out = call(json!({
2359 "api_version": 1, "command": "reticle.hold",
2360 "request": {"catalog": "tremor-9000", "drop_mil": 3.0, "magnification": 10.0},
2361 }));
2362 assert_eq!(out["ok"], false);
2363 assert_eq!(out["error"]["code"], "invalid_request");
2364 assert!(out["error"]["message"]
2365 .as_str()
2366 .unwrap()
2367 .contains("reticle.catalog"));
2368 }
2369
2370 #[test]
2371 fn naming_a_reticle_three_ways_is_refused_too() {
2372 let out = call(json!({
2374 "api_version": 1, "command": "reticle.describe",
2375 "request": {
2376 "catalog": "mil-dot",
2377 "generator": {"kind": "mil_grid", "spacing_mil": 1.0, "extent_mil": 5.0},
2378 },
2379 }));
2380 assert_eq!(out["ok"], false);
2381 assert_eq!(out["error"]["code"], "invalid_request");
2382 }
2383
2384 #[test]
2385 fn the_catalog_lists_what_the_other_commands_will_accept() {
2386 let out = call(json!({"api_version": 1, "command": "reticle.catalog"}));
2389 assert_eq!(out["ok"], true, "{out}");
2390 let reticles = out["result"]["reticles"].as_array().unwrap().clone();
2391 assert!(!reticles.is_empty());
2392 for entry in reticles {
2393 let id = entry["id"].as_str().unwrap();
2394 assert!(
2396 !entry["source"].as_str().unwrap().is_empty(),
2397 "{id} has no source"
2398 );
2399 assert!(
2400 !entry["notes"].as_str().unwrap().is_empty(),
2401 "{id} has no notes"
2402 );
2403 assert!(
2404 entry["mark_count"].as_u64().unwrap() > 0,
2405 "{id} has no marks"
2406 );
2407
2408 let held = call(json!({
2409 "api_version": 1, "command": "reticle.hold",
2410 "request": {"catalog": id, "drop_mil": 1.0, "magnification": 10.0},
2411 }));
2412 assert_eq!(held["ok"], true, "listed id {id} does not resolve: {held}");
2413 }
2414 }
2415
2416 #[test]
2417 fn a_batch_of_holds_comes_back_in_request_order() {
2418 let out = call(json!({
2420 "api_version": 1, "command": "reticle.holds",
2421 "request": {
2422 "catalog": "mil-dot",
2423 "magnification": 10.0,
2424 "holds": [
2425 {"drop_mil": 1.0},
2426 {"drop_mil": 3.0, "wind_mil": 0.0},
2427 {"drop_mil": 2.0},
2428 ],
2429 },
2430 }));
2431 assert_eq!(out["ok"], true, "{out}");
2432 let holds = out["holds"].clone();
2433 assert!(holds.is_null(), "holds live under result, not the envelope");
2434 let holds = out["result"]["holds"].as_array().unwrap().clone();
2435 assert_eq!(holds.len(), 3);
2436 assert_eq!(holds[0]["nearest_mark"]["nominal"]["down_mil"], 1.0);
2438 assert_eq!(holds[1]["nearest_mark"]["nominal"]["down_mil"], 3.0);
2439 assert_eq!(holds[2]["nearest_mark"]["nominal"]["down_mil"], 2.0);
2440 assert_eq!(out["result"]["mark_scale"], 1.0);
2442 assert!(holds[0]["hold"]["mark_scale"].is_null());
2443 }
2444
2445 #[test]
2446 fn a_batch_agrees_with_the_single_hold_command() {
2447 let single = call(json!({
2450 "api_version": 1, "command": "reticle.hold",
2451 "request": {"catalog": "mil-dot", "drop_mil": 2.4, "wind_mil": 0.7, "magnification": 10.0},
2452 }));
2453 let batch = call(json!({
2454 "api_version": 1, "command": "reticle.holds",
2455 "request": {
2456 "catalog": "mil-dot", "magnification": 10.0,
2457 "holds": [{"drop_mil": 2.4, "wind_mil": 0.7}],
2458 },
2459 }));
2460 assert_eq!(single["ok"], true);
2461 assert_eq!(batch["ok"], true);
2462 assert_eq!(
2463 single["result"]["nearest_mark"],
2464 batch["result"]["holds"][0]["nearest_mark"]
2465 );
2466 assert_eq!(
2467 single["result"]["hold"]["off_reticle"],
2468 batch["result"]["holds"][0]["hold"]["off_reticle"]
2469 );
2470 assert_eq!(
2471 single["result"]["hold"]["nearest_mark_distance_mil"],
2472 batch["result"]["holds"][0]["hold"]["nearest_mark_distance_mil"]
2473 );
2474 }
2475
2476 #[test]
2477 fn a_malformed_hold_entry_refuses_the_whole_batch() {
2478 for bad in [
2491 json!(null),
2492 json!("3.0"),
2493 json!({"drop_mil": 1.0, "nonsense": 2}),
2494 ] {
2495 let out = call(json!({
2496 "api_version": 1, "command": "reticle.holds",
2497 "request": {
2498 "catalog": "mil-dot", "magnification": 10.0,
2499 "holds": [{"drop_mil": 1.0}, bad],
2500 },
2501 }));
2502 assert_eq!(out["ok"], false, "accepted a malformed entry: {out}");
2503 assert_eq!(out["error"]["code"], "invalid_request");
2504 }
2505 }
2506
2507 #[test]
2508 fn a_bad_magnification_fails_the_batch_before_any_row() {
2509 let out = call(json!({
2511 "api_version": 1, "command": "reticle.holds",
2512 "request": {
2513 "catalog": "mil-dot", "magnification": 0.0,
2514 "holds": [{"drop_mil": 1.0}, {"drop_mil": 2.0}],
2515 },
2516 }));
2517 assert_eq!(out["ok"], false);
2518 assert_eq!(
2519 out["error"]["details"]["reason"],
2520 "non_positive_magnification"
2521 );
2522 }
2523
2524 #[test]
2525 fn an_empty_batch_is_an_empty_list_not_an_error() {
2526 let out = call(json!({
2528 "api_version": 1, "command": "reticle.holds",
2529 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": []},
2530 }));
2531 assert_eq!(out["ok"], true, "{out}");
2532 assert_eq!(out["result"]["holds"].as_array().unwrap().len(), 0);
2533 }
2534
2535 #[test]
2536 fn too_many_holds_is_a_resource_limit() {
2537 let holds: Vec<Value> = (0..MAX_RETICLE_HOLDS + 1)
2538 .map(|i| json!({"drop_mil": (i % 5) as f64}))
2539 .collect();
2540 let out = call(json!({
2541 "api_version": 1, "command": "reticle.holds",
2542 "request": {"catalog": "mil-dot", "magnification": 10.0, "holds": holds},
2543 }));
2544 assert_eq!(out["ok"], false);
2545 assert_eq!(out["error"]["code"], "resource_limit");
2546 }
2547
2548 #[test]
2549 fn capabilities_lists_the_catalog_command() {
2550 let out = call(json!({"api_version": 1, "command": "meta.capabilities"}));
2551 let commands: Vec<String> =
2552 serde_json::from_value(out["result"]["commands"].clone()).unwrap();
2553 assert!(commands.contains(&"reticle.catalog".to_string()));
2554 assert!(commands.contains(&"reticle.holds".to_string()));
2555 }
2556
2557 #[test]
2558 fn naming_no_reticle_at_all_is_refused() {
2559 let out = call(json!({
2560 "api_version": 1, "command": "reticle.hold",
2561 "request": {"drop_mil": 1.0, "magnification": 10.0},
2562 }));
2563 assert_eq!(out["ok"], false);
2564 assert_eq!(out["error"]["code"], "invalid_request");
2565 }
2566
2567 #[test]
2568 fn plane_overrides_beside_a_full_reticle_are_refused() {
2569 let out = call(json!({
2572 "api_version": 1, "command": "reticle.describe",
2573 "request": {"reticle": hash_reticle(), "focal_plane": "sfp"},
2574 }));
2575 assert_eq!(out["ok"], false);
2576 assert_eq!(out["error"]["code"], "invalid_request");
2577 }
2578
2579 #[test]
2580 fn a_reticle_error_carries_a_stable_reason_and_its_numbers() {
2581 let out = call(json!({
2585 "api_version": 1, "command": "reticle.hold",
2586 "request": {"reticle": hash_reticle(), "drop_mil": 1.0, "magnification": 0.0},
2587 }));
2588 assert_eq!(out["ok"], false);
2589 assert_eq!(out["error"]["code"], "command_failed");
2590 assert_eq!(
2591 out["error"]["details"]["reason"],
2592 "non_positive_magnification"
2593 );
2594 assert_eq!(out["error"]["details"]["magnification"], 0.0);
2595 assert_eq!(out["error"]["details"]["command"], "reticle.hold");
2596 }
2597
2598 #[test]
2599 fn a_reticle_with_no_marks_is_refused_by_describe_too() {
2600 let mut empty = hash_reticle();
2603 empty["marks"] = json!([]);
2604 let out = call(json!({
2605 "api_version": 1, "command": "reticle.describe",
2606 "request": {"reticle": empty},
2607 }));
2608 assert_eq!(out["ok"], false);
2609 assert_eq!(out["error"]["details"]["reason"], "no_marks");
2610 }
2611
2612 #[test]
2613 fn an_unreadable_generator_parameter_names_the_parameter() {
2614 let out = call(json!({
2615 "api_version": 1, "command": "reticle.describe",
2616 "request": {"generator": {"kind": "mil_grid", "spacing_mil": 0.0, "extent_mil": 5.0}},
2617 }));
2618 assert_eq!(out["ok"], false);
2619 assert_eq!(
2620 out["error"]["details"]["reason"],
2621 "invalid_generator_parameter"
2622 );
2623 assert_eq!(out["error"]["details"]["parameter"], "spacing");
2624 }
2625
2626 #[test]
2627 fn importing_a_ventum_document_returns_the_reticle_and_its_report() {
2628 let document = r#"{"name":"MBR","plane":"ffp","unit":"mil","spec":[
2634 {"type":"dot","y":4,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":9,"mirror":true}},
2635 {"type":"dot","y":8,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":13,"mirror":true}},
2636 {"type":"dot","y":12,"x":1,"r":0.12,"repeat":{"axis":"x","step":1,"n":17,"mirror":true}}]}"#;
2637
2638 let out = call(json!({
2639 "api_version": 1, "command": "reticle.import",
2640 "request": {"format": "ventum", "document": document},
2641 }));
2642 assert_eq!(out["ok"], true, "{out}");
2643 assert_eq!(out["result"]["format"], "ventum");
2644 assert_eq!(out["result"]["mark_count"], 78);
2645 assert_eq!(out["result"]["reticle"]["name"], "MBR");
2646 assert_eq!(out["result"]["reticle"]["focal_plane"], "ffp");
2647 assert_eq!(out["result"]["report"]["dropped_elements"], 0);
2649 assert_eq!(out["result"]["report"]["arcs_unresolved"], 0);
2650
2651 let held = call(json!({
2653 "api_version": 1, "command": "reticle.hold",
2654 "request": {
2655 "reticle": out["result"]["reticle"],
2656 "drop_mil": 8.0,
2657 "wind_mil": 2.0,
2658 "magnification": 10.0,
2659 },
2660 }));
2661 assert_eq!(held["ok"], true, "{held}");
2662 assert_eq!(held["result"]["hold"]["off_reticle"], false);
2663 assert_eq!(held["result"]["nearest_mark"]["nominal"]["down_mil"], 8.0);
2664 assert_eq!(held["result"]["nearest_mark"]["nominal"]["right_mil"], 2.0);
2665 assert_eq!(held["result"]["hold"]["nearest_mark_distance_mil"], 0.0);
2666 }
2667
2668 #[test]
2669 fn a_document_that_is_not_the_named_format_fails_as_a_command_failure() {
2670 let out = call(json!({
2671 "api_version": 1, "command": "reticle.import",
2672 "request": {"format": "reticle_xml", "document": "{\"this\": \"is json\"}"},
2673 }));
2674 assert_eq!(out["ok"], false);
2675 assert_eq!(out["error"]["code"], "command_failed");
2676 }
2677
2678 #[test]
2679 fn an_oversize_reticle_document_is_a_resource_limit_not_a_parse_failure() {
2680 let out = call(json!({
2683 "api_version": 1, "command": "reticle.import",
2684 "request": {
2685 "format": "ventum",
2686 "document": "x".repeat(MAX_RETICLE_DOCUMENT_BYTES + 1),
2687 },
2688 }));
2689 assert_eq!(out["ok"], false);
2690 assert_eq!(out["error"]["code"], "resource_limit");
2691 assert!(out["error"]["message"]
2692 .as_str()
2693 .unwrap()
2694 .contains("reticle document"));
2695 }
2696
2697 #[test]
2698 fn every_reticle_command_rejects_an_unknown_request_field() {
2699 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2700 let out = call(json!({
2701 "api_version": 1,
2702 "command": command,
2703 "request": {"nonsense": 1},
2704 }));
2705 assert_eq!(out["ok"], false, "{command} accepted an unknown field");
2706 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2707 }
2708 }
2709
2710 #[test]
2711 fn every_reticle_command_rejects_a_missing_payload() {
2712 for command in ["reticle.describe", "reticle.hold", "reticle.import"] {
2713 let out = call(json!({"api_version": 1, "command": command}));
2714 assert_eq!(out["ok"], false, "{command} accepted a null payload");
2715 assert_eq!(out["error"]["code"], "invalid_request", "{command}");
2716 }
2717 }
2718}