1use serde::{
8 de::{self, Visitor},
9 ser::SerializeStruct,
10 Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::{Map, Value};
13use std::{fmt, num::NonZeroUsize};
14
15pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18pub const DRAG_MODEL_WIRE_NAMES_V1: [&str; 9] =
23 ["G1", "G2", "G5", "G6", "G7", "G8", "GI", "GS", "RA4"];
24
25pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
30
31fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
37where
38 D: Deserializer<'de>,
39 T: Deserialize<'de>,
40{
41 T::deserialize(deserializer).map(Some)
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
49pub struct SchemaVersionV1;
50
51impl SchemaVersionV1 {
52 pub const fn get(self) -> u32 {
54 SOLVE_JSON_SCHEMA_VERSION_V1
55 }
56}
57
58impl Serialize for SchemaVersionV1 {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: Serializer,
62 {
63 serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
64 }
65}
66
67impl<'de> Deserialize<'de> for SchemaVersionV1 {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: Deserializer<'de>,
71 {
72 struct SchemaVersionVisitor;
73
74 impl<'de> Visitor<'de> for SchemaVersionVisitor {
75 type Value = SchemaVersionV1;
76
77 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str("the integer 1")
79 }
80
81 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
82 where
83 E: de::Error,
84 {
85 if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
86 Ok(SchemaVersionV1)
87 } else {
88 Err(E::custom(format!(
89 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
90 )))
91 }
92 }
93
94 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
95 where
96 E: de::Error,
97 {
98 if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
99 Ok(SchemaVersionV1)
100 } else {
101 Err(E::custom(format!(
102 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
103 )))
104 }
105 }
106 }
107
108 deserializer.deserialize_any(SchemaVersionVisitor)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct SolveRequestV1 {
119 pub schema_version: SchemaVersionV1,
121 pub projectile: ProjectileV1,
122 pub rifle: RifleV1,
123 pub shot: ShotV1,
124 pub atmosphere: AtmosphereV1,
125 pub wind: WindV1,
126 pub solver: SolverV1,
127 pub effects: EffectsV1,
128 pub sampling: SamplingV1,
129 #[serde(
136 default,
137 skip_serializing_if = "Option::is_none",
138 deserialize_with = "deserialize_present"
139 )]
140 pub reticle: Option<ReticleRequestV1>,
141 #[serde(
147 default,
148 skip_serializing_if = "Option::is_none",
149 deserialize_with = "deserialize_present"
150 )]
151 pub corrections: Option<CorrectionsV1>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct CorrectionsV1 {
169 #[serde(
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present"
174 )]
175 pub bc5d_table_path: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ReticleRequestV1 {
188 pub range_m: f64,
190 pub magnification: f64,
193 pub description: crate::reticle::ReticleDescription,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ReticleHoldV1 {
203 pub range_m: f64,
205 pub magnification: f64,
207 pub down_mil: f64,
208 pub right_mil: f64,
209 pub mark_scale: f64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub nearest_mark_index: Option<usize>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub nearest_mark_label: Option<String>,
219 pub nearest_mark_distance_mil: f64,
220 pub off_reticle: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ProjectileV1 {
229 pub mass_kg: f64,
230 pub diameter_m: f64,
231 #[serde(
232 default,
233 skip_serializing_if = "Option::is_none",
234 deserialize_with = "deserialize_present"
235 )]
236 pub length_m: Option<f64>,
237 pub drag_model: DragModelV1,
238 pub ballistic_coefficient: f64,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum DragModelV1 {
244 #[serde(rename = "G1")]
245 G1,
246 #[serde(rename = "G6")]
247 G6,
248 #[serde(rename = "G7")]
249 G7,
250 #[serde(rename = "G8")]
251 G8,
252 #[serde(rename = "G2")]
253 G2,
254 #[serde(rename = "G5")]
255 G5,
256 #[serde(rename = "GI")]
257 GI,
258 #[serde(rename = "GS")]
259 GS,
260 #[serde(rename = "RA4")]
261 RA4,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct RifleV1 {
268 pub muzzle_velocity_mps: f64,
269 #[serde(
270 default,
271 skip_serializing_if = "Option::is_none",
272 deserialize_with = "deserialize_present"
273 )]
274 pub sight_height_m: Option<f64>,
275 #[serde(
276 default,
277 skip_serializing_if = "Option::is_none",
278 deserialize_with = "deserialize_present"
279 )]
280 pub muzzle_height_m: Option<f64>,
281 #[serde(
282 default,
283 skip_serializing_if = "Option::is_none",
284 deserialize_with = "deserialize_present"
285 )]
286 pub twist_rate_m_per_turn: Option<f64>,
287 #[serde(
288 default,
289 skip_serializing_if = "Option::is_none",
290 deserialize_with = "deserialize_present"
291 )]
292 pub twist_direction: Option<TwistDirectionV1>,
293 #[serde(
301 default,
302 skip_serializing_if = "Option::is_none",
303 deserialize_with = "deserialize_present"
304 )]
305 pub sight_offset_lateral_m: Option<f64>,
306}
307
308#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum TwistDirectionV1 {
312 Left,
313 #[default]
314 Right,
315}
316
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct ShotV1 {
321 pub max_range_m: f64,
322 #[serde(
323 default,
324 skip_serializing_if = "Option::is_none",
325 deserialize_with = "deserialize_present"
326 )]
327 pub zero_distance_m: Option<f64>,
328 #[serde(
329 default,
330 skip_serializing_if = "Option::is_none",
331 deserialize_with = "deserialize_present"
332 )]
333 pub muzzle_angle_rad: Option<f64>,
334 #[serde(
335 default,
336 skip_serializing_if = "Option::is_none",
337 deserialize_with = "deserialize_present"
338 )]
339 pub aim_azimuth_rad: Option<f64>,
340 #[serde(
341 default,
342 skip_serializing_if = "Option::is_none",
343 deserialize_with = "deserialize_present"
344 )]
345 pub shot_azimuth_rad: Option<f64>,
346 #[serde(
347 default,
348 skip_serializing_if = "Option::is_none",
349 deserialize_with = "deserialize_present"
350 )]
351 pub shooting_angle_rad: Option<f64>,
352 #[serde(
353 default,
354 skip_serializing_if = "Option::is_none",
355 deserialize_with = "deserialize_present"
356 )]
357 pub cant_angle_rad: Option<f64>,
358 #[serde(
376 default,
377 skip_serializing_if = "Option::is_none",
378 deserialize_with = "deserialize_present"
379 )]
380 pub target_height_m: Option<f64>,
381 #[serde(
382 default,
383 skip_serializing_if = "Option::is_none",
384 deserialize_with = "deserialize_present"
385 )]
386 pub ground_threshold_m: Option<f64>,
387 #[serde(
393 default,
394 skip_serializing_if = "Option::is_none",
395 deserialize_with = "deserialize_present"
396 )]
397 pub zero_poi_up_m: Option<f64>,
398 #[serde(
402 default,
403 skip_serializing_if = "Option::is_none",
404 deserialize_with = "deserialize_present"
405 )]
406 pub zero_poi_right_m: Option<f64>,
407 #[serde(
415 default,
416 skip_serializing_if = "Option::is_none",
417 deserialize_with = "deserialize_present"
418 )]
419 pub drops_reference: Option<DropsReferenceV1>,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
424#[serde(rename_all = "snake_case")]
425pub enum DropsReferenceV1 {
426 #[default]
428 Los,
429 Target,
432}
433
434#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
449#[serde(deny_unknown_fields)]
450pub struct AtmosphereV1 {
451 #[serde(
452 default,
453 skip_serializing_if = "Option::is_none",
454 deserialize_with = "deserialize_present"
455 )]
456 pub altitude_m: Option<f64>,
457 #[serde(
460 default,
461 skip_serializing_if = "Option::is_none",
462 deserialize_with = "deserialize_present"
463 )]
464 pub temperature_k: Option<f64>,
465 #[serde(
471 default,
472 skip_serializing_if = "Option::is_none",
473 deserialize_with = "deserialize_present"
474 )]
475 pub pressure_pa: Option<f64>,
476 #[serde(
482 default,
483 skip_serializing_if = "Option::is_none",
484 deserialize_with = "deserialize_present"
485 )]
486 pub pressure_reference: Option<PressureReferenceV1>,
487 #[serde(
488 default,
489 skip_serializing_if = "Option::is_none",
490 deserialize_with = "deserialize_present"
491 )]
492 pub relative_humidity: Option<f64>,
493 #[serde(
494 default,
495 skip_serializing_if = "Option::is_none",
496 deserialize_with = "deserialize_present"
497 )]
498 pub latitude_rad: Option<f64>,
499}
500
501#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(rename_all = "snake_case")]
506pub enum PressureReferenceV1 {
507 #[default]
508 Absolute,
509 Qnh,
510}
511
512#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct WindV1 {
519 #[serde(
520 default,
521 skip_serializing_if = "Option::is_none",
522 deserialize_with = "deserialize_present"
523 )]
524 pub speed_mps: Option<f64>,
525 #[serde(
526 default,
527 skip_serializing_if = "Option::is_none",
528 deserialize_with = "deserialize_present"
529 )]
530 pub direction_from_rad: Option<f64>,
531 #[serde(
532 default,
533 skip_serializing_if = "Option::is_none",
534 deserialize_with = "deserialize_present"
535 )]
536 pub vertical_speed_mps: Option<f64>,
537 #[serde(
538 default,
539 skip_serializing_if = "Option::is_none",
540 deserialize_with = "deserialize_present"
541 )]
542 pub segments: Option<Vec<WindSegmentV1>>,
543 #[serde(
556 default,
557 skip_serializing_if = "Option::is_none",
558 deserialize_with = "deserialize_present"
559 )]
560 pub wind_reference: Option<WindReferenceV1>,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum WindReferenceV1 {
567 #[default]
569 Shooter,
570 Compass,
572}
573
574#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct WindSegmentV1 {
578 pub until_distance_m: f64,
579 pub speed_mps: f64,
580 pub direction_from_rad: f64,
581 #[serde(
582 default,
583 skip_serializing_if = "Option::is_none",
584 deserialize_with = "deserialize_present"
585 )]
586 pub vertical_speed_mps: Option<f64>,
587}
588
589#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
591#[serde(deny_unknown_fields)]
592pub struct SolverV1 {
593 #[serde(
594 default,
595 skip_serializing_if = "Option::is_none",
596 deserialize_with = "deserialize_present"
597 )]
598 pub method: Option<SolverMethodV1>,
599 #[serde(
601 default,
602 skip_serializing_if = "Option::is_none",
603 deserialize_with = "deserialize_present"
604 )]
605 pub time_step_s: Option<f64>,
606}
607
608#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(rename_all = "snake_case")]
611pub enum SolverMethodV1 {
612 Euler,
613 Rk4,
614 #[default]
615 Rk45,
616}
617
618#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(deny_unknown_fields)]
621pub struct EffectsV1 {
622 #[serde(
623 default,
624 skip_serializing_if = "Option::is_none",
625 deserialize_with = "deserialize_present"
626 )]
627 pub magnus: Option<bool>,
628 #[serde(
629 default,
630 skip_serializing_if = "Option::is_none",
631 deserialize_with = "deserialize_present"
632 )]
633 pub coriolis: Option<bool>,
634 #[serde(
635 default,
636 skip_serializing_if = "Option::is_none",
637 deserialize_with = "deserialize_present"
638 )]
639 pub enhanced_spin_drift: Option<bool>,
640 #[serde(
650 default,
651 skip_serializing_if = "Option::is_none",
652 deserialize_with = "deserialize_present"
653 )]
654 pub wind_shear_model: Option<WindShearModelV1>,
655 #[serde(
672 default,
673 skip_serializing_if = "Option::is_none",
674 deserialize_with = "deserialize_present"
675 )]
676 pub aerodynamic_jump: Option<bool>,
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
687#[serde(rename_all = "snake_case")]
688pub enum WindShearModelV1 {
689 #[default]
692 None,
693 Logarithmic,
695 PowerLaw,
697 #[serde(alias = "ekman")]
702 EkmanSpiral,
703}
704
705impl WindShearModelV1 {
706 pub fn as_engine_str(self) -> &'static str {
711 match self {
712 WindShearModelV1::None => "none",
713 WindShearModelV1::Logarithmic => "logarithmic",
714 WindShearModelV1::PowerLaw => "power_law",
715 WindShearModelV1::EkmanSpiral => "ekman_spiral",
716 }
717 }
718
719 pub fn is_enabled(self) -> bool {
726 !matches!(self, WindShearModelV1::None)
727 }
728}
729
730#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
732#[serde(deny_unknown_fields)]
733pub struct SamplingV1 {
734 #[serde(
735 default,
736 skip_serializing_if = "Option::is_none",
737 deserialize_with = "deserialize_present"
738 )]
739 pub interval_m: Option<f64>,
740}
741
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
748#[serde(deny_unknown_fields)]
749pub struct ResolvedSolveRequestV1 {
750 pub schema_version: SchemaVersionV1,
751 pub projectile: ResolvedProjectileV1,
752 pub rifle: ResolvedRifleV1,
753 pub shot: ResolvedShotV1,
754 pub atmosphere: ResolvedAtmosphereV1,
755 pub wind: ResolvedWindV1,
756 pub solver: ResolvedSolverV1,
757 pub effects: ResolvedEffectsV1,
758 pub sampling: ResolvedSamplingV1,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub reticle: Option<ReticleRequestV1>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub corrections: Option<CorrectionsV1>,
771}
772
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct ResolvedProjectileV1 {
777 pub mass_kg: f64,
778 pub diameter_m: f64,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub length_m: Option<f64>,
781 pub drag_model: DragModelV1,
782 pub ballistic_coefficient: f64,
783}
784
785#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
787#[serde(deny_unknown_fields)]
788pub struct ResolvedRifleV1 {
789 pub muzzle_velocity_mps: f64,
790 pub sight_height_m: f64,
791 pub muzzle_height_m: f64,
792 pub twist_rate_m_per_turn: f64,
793 pub twist_direction: TwistDirectionV1,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub sight_offset_lateral_m: Option<f64>,
798}
799
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
812#[serde(deny_unknown_fields)]
813pub struct ResolvedShotV1 {
814 pub max_range_m: f64,
815 #[serde(default, skip_serializing_if = "Option::is_none")]
816 pub zero_distance_m: Option<f64>,
817 pub muzzle_angle_rad: f64,
818 pub aim_azimuth_rad: f64,
819 pub shot_azimuth_rad: f64,
820 pub shooting_angle_rad: f64,
821 pub cant_angle_rad: f64,
822 pub target_height_m: f64,
823 pub ground_threshold_m: f64,
824 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub zero_poi_up_m: Option<f64>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
831 pub zero_poi_right_m: Option<f64>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
835 pub drops_reference: Option<DropsReferenceV1>,
836}
837
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
840#[serde(deny_unknown_fields)]
841pub struct ResolvedAtmosphereV1 {
842 pub altitude_m: f64,
843 pub temperature_k: f64,
844 pub pressure_pa: f64,
845 pub relative_humidity: f64,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub latitude_rad: Option<f64>,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub pressure_reference: Option<PressureReferenceV1>,
852}
853
854#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
859#[serde(untagged)]
860pub enum ResolvedWindV1 {
861 Constant(ResolvedConstantWindV1),
862 Segmented(ResolvedSegmentedWindV1),
863}
864
865#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
867#[serde(deny_unknown_fields)]
868pub struct ResolvedConstantWindV1 {
869 pub speed_mps: f64,
870 pub direction_from_rad: f64,
871 pub vertical_speed_mps: f64,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
876 pub wind_reference: Option<WindReferenceV1>,
877}
878
879#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields)]
882pub struct ResolvedSegmentedWindV1 {
883 pub segments: Vec<ResolvedWindSegmentV1>,
884 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub wind_reference: Option<WindReferenceV1>,
889}
890
891#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
893#[serde(deny_unknown_fields)]
894pub struct ResolvedWindSegmentV1 {
895 pub until_distance_m: f64,
896 pub speed_mps: f64,
897 pub direction_from_rad: f64,
898 pub vertical_speed_mps: f64,
899}
900
901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
903#[serde(deny_unknown_fields)]
904pub struct ResolvedSolverV1 {
905 pub method: SolverMethodV1,
906 pub time_step_s: f64,
907}
908
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
911#[serde(deny_unknown_fields)]
912pub struct ResolvedEffectsV1 {
913 pub magnus: bool,
914 pub coriolis: bool,
915 pub enhanced_spin_drift: bool,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub wind_shear_model: Option<WindShearModelV1>,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
931 pub aerodynamic_jump: Option<bool>,
932}
933
934#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
936#[serde(deny_unknown_fields)]
937pub struct ResolvedSamplingV1 {
938 pub interval_m: f64,
939}
940
941#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
943#[serde(deny_unknown_fields)]
944pub struct SolveSuccessV1 {
945 pub schema_version: SchemaVersionV1,
946 pub engine_version: String,
947 pub status: SuccessStatusV1,
948 pub resolved_request: ResolvedSolveRequestV1,
949 #[serde(default)]
950 pub assumptions: Vec<SolveNoticeV1>,
951 #[serde(default)]
952 pub warnings: Vec<SolveNoticeV1>,
953 pub summary: SolveSummaryV1,
954 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
955 pub samples: Vec<TrajectorySampleV1>,
956 #[serde(default, skip_serializing_if = "Option::is_none")]
960 pub reticle_hold: Option<ReticleHoldV1>,
961}
962
963fn serialize_solve_samples_v1<S>(
964 samples: &[TrajectorySampleV1],
965 serializer: S,
966) -> Result<S::Ok, S::Error>
967where
968 S: Serializer,
969{
970 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
971 return Err(serde::ser::Error::custom(format_args!(
972 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
973 samples.len()
974 )));
975 }
976 samples.serialize(serializer)
977}
978
979impl SolveSuccessV1 {
980 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
986 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
987 return Ok(());
988 }
989
990 Err(SolveErrorEnvelopeV1::new(
991 SolveErrorV1::new(
992 SolveErrorCodeV1::ResourceLimit,
993 format!(
994 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
995 self.samples.len()
996 ),
997 )
998 .at_path("$.sampling.interval_m"),
999 ))
1000 }
1001}
1002
1003#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1005#[serde(rename_all = "snake_case")]
1006pub enum SuccessStatusV1 {
1007 Ok,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1012#[serde(deny_unknown_fields)]
1013pub struct SolveNoticeV1 {
1014 pub code: String,
1015 pub message: String,
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub path: Option<String>,
1018}
1019
1020#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1022#[serde(deny_unknown_fields)]
1023pub struct SolveSummaryV1 {
1024 pub actual_range_m: f64,
1025 pub maximum_height_m: f64,
1028 pub time_of_flight_s: f64,
1029 pub terminal_speed_mps: f64,
1030 pub terminal_energy_j: f64,
1031 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub stability_factor: Option<f64>,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub spin_drift_m: Option<f64>,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub equivalent_horizontal_range_m: Option<f64>,
1049 #[serde(default, skip_serializing_if = "Option::is_none")]
1061 pub aerodynamic_jump_moa: Option<f64>,
1062 pub termination: TerminationReasonV1,
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1067#[serde(rename_all = "snake_case")]
1068pub enum TerminationReasonV1 {
1069 MaxRange,
1070 GroundThreshold,
1071 TimeLimit,
1072 VelocityFloor,
1073}
1074
1075#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1077#[serde(deny_unknown_fields)]
1078pub struct TrajectorySampleV1 {
1079 pub distance_m: f64,
1080 pub time_s: f64,
1081 pub speed_mps: f64,
1082 pub energy_j: f64,
1083 pub drop_m: f64,
1085 pub windage_m: f64,
1087 pub mach: f64,
1088 #[serde(default)]
1089 pub flags: Vec<SampleFlagV1>,
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1094#[serde(rename_all = "snake_case")]
1095pub enum SampleFlagV1 {
1096 Transonic,
1097 Subsonic,
1098 Terminal,
1099 GroundThreshold,
1100}
1101
1102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1104#[serde(deny_unknown_fields)]
1105pub struct SolveErrorEnvelopeV1 {
1106 pub schema_version: SchemaVersionV1,
1107 pub status: ErrorStatusV1,
1108 pub error: SolveErrorV1,
1109}
1110
1111impl SolveErrorEnvelopeV1 {
1112 pub fn new(error: SolveErrorV1) -> Self {
1114 Self {
1115 schema_version: SchemaVersionV1,
1116 status: ErrorStatusV1::Error,
1117 error,
1118 }
1119 }
1120}
1121
1122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1124#[serde(rename_all = "snake_case")]
1125pub enum ErrorStatusV1 {
1126 Error,
1127}
1128
1129#[derive(Debug, Clone, PartialEq, Eq)]
1135pub struct SolveErrorV1 {
1136 pub code: SolveErrorCodeV1,
1137 pub message: String,
1138 location: SolveErrorLocationV1,
1139}
1140
1141#[derive(Debug, Clone, PartialEq, Eq)]
1142enum SolveErrorLocationV1 {
1143 None,
1144 Path(String),
1145 Source {
1146 line: NonZeroUsize,
1147 column: NonZeroUsize,
1148 },
1149}
1150
1151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1153pub enum SolveErrorLocationErrorV1 {
1154 ZeroLine,
1155 ZeroColumn,
1156}
1157
1158impl fmt::Display for SolveErrorLocationErrorV1 {
1159 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1160 match self {
1161 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1162 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1163 }
1164 }
1165}
1166
1167impl std::error::Error for SolveErrorLocationErrorV1 {}
1168
1169impl SolveErrorV1 {
1170 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1172 Self {
1173 code,
1174 message: message.into(),
1175 location: SolveErrorLocationV1::None,
1176 }
1177 }
1178
1179 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1181 self.location = SolveErrorLocationV1::Path(path.into());
1182 self
1183 }
1184
1185 pub fn at_location(
1187 mut self,
1188 line: usize,
1189 column: usize,
1190 ) -> Result<Self, SolveErrorLocationErrorV1> {
1191 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1192 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1193 self.location = SolveErrorLocationV1::Source { line, column };
1194 Ok(self)
1195 }
1196
1197 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1202 self.location = SolveErrorLocationV1::Source {
1203 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1204 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1205 };
1206 self
1207 }
1208
1209 pub fn path(&self) -> Option<&str> {
1211 match &self.location {
1212 SolveErrorLocationV1::Path(path) => Some(path),
1213 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1214 }
1215 }
1216
1217 pub fn line(&self) -> Option<usize> {
1219 match &self.location {
1220 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1221 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1222 }
1223 }
1224
1225 pub fn column(&self) -> Option<usize> {
1227 match &self.location {
1228 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1229 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1230 }
1231 }
1232}
1233
1234impl Serialize for SolveErrorV1 {
1235 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1236 where
1237 S: Serializer,
1238 {
1239 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1240 state.serialize_field("code", &self.code)?;
1241 state.serialize_field("message", &self.message)?;
1242 state.serialize_field("path", &self.path())?;
1243 state.serialize_field("line", &self.line())?;
1244 state.serialize_field("column", &self.column())?;
1245 state.end()
1246 }
1247}
1248
1249impl<'de> Deserialize<'de> for SolveErrorV1 {
1250 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1251 where
1252 D: Deserializer<'de>,
1253 {
1254 #[derive(Deserialize)]
1255 #[serde(deny_unknown_fields)]
1256 struct SolveErrorWireV1 {
1257 code: SolveErrorCodeV1,
1258 message: String,
1259 path: Option<String>,
1260 line: Option<usize>,
1261 column: Option<usize>,
1262 }
1263
1264 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1265 let location = match (wire.path, wire.line, wire.column) {
1266 (None, None, None) => SolveErrorLocationV1::None,
1267 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1268 (None, Some(line), Some(column)) => {
1269 let line = NonZeroUsize::new(line)
1270 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1271 let column = NonZeroUsize::new(column)
1272 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1273 SolveErrorLocationV1::Source { line, column }
1274 }
1275 _ => {
1276 return Err(de::Error::custom(
1277 "error location must contain either path or both line and column",
1278 ));
1279 }
1280 };
1281
1282 Ok(Self {
1283 code: wire.code,
1284 message: wire.message,
1285 location,
1286 })
1287 }
1288}
1289
1290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1292#[serde(rename_all = "snake_case")]
1293pub enum SolveErrorCodeV1 {
1294 InvalidJson,
1295 UnsupportedSchemaVersion,
1296 UnknownField,
1297 MissingField,
1298 InvalidValue,
1299 ConflictingFields,
1300 ResourceLimit,
1301 SolveFailed,
1302 IoError,
1303 InternalError,
1304}
1305
1306pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1313 let value: Value = serde_json::from_str(input).map_err(|error| {
1314 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1315 .at_parser_location(error.line(), error.column());
1316 envelope(error)
1317 })?;
1318
1319 validate_request_shape(&value)?;
1320
1321 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1322 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1323 })?;
1324
1325 validate_request_ranges(&request)?;
1326
1327 Ok(request)
1328}
1329
1330mod limits {
1343 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1347 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1349 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1351 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1353
1354 }
1361
1362fn require_range(
1363 value: f64,
1364 (min, max): (f64, f64),
1365 path: &str,
1366) -> Result<(), SolveErrorEnvelopeV1> {
1367 if !value.is_finite() {
1368 return Err(protocol_error(
1369 SolveErrorCodeV1::InvalidValue,
1370 format!("{path} must be a finite number"),
1371 path,
1372 ));
1373 }
1374 if value < min || value > max {
1375 return Err(protocol_error(
1376 SolveErrorCodeV1::InvalidValue,
1377 format!("{path} must be between {min} and {max}, got {value}"),
1378 path,
1379 ));
1380 }
1381 Ok(())
1382}
1383
1384fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1389 require_range(
1390 request.projectile.mass_kg,
1391 limits::MASS_KG,
1392 "$.projectile.mass_kg",
1393 )?;
1394 require_range(
1395 request.projectile.diameter_m,
1396 limits::DIAMETER_M,
1397 "$.projectile.diameter_m",
1398 )?;
1399 require_range(
1400 request.projectile.ballistic_coefficient,
1401 limits::BALLISTIC_COEFFICIENT,
1402 "$.projectile.ballistic_coefficient",
1403 )?;
1404 if let Some(length_m) = request.projectile.length_m {
1405 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1406 }
1407 Ok(())
1408}
1409
1410fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1411 SolveErrorEnvelopeV1::new(error)
1412}
1413
1414fn protocol_error(
1415 code: SolveErrorCodeV1,
1416 message: impl Into<String>,
1417 path: impl Into<String>,
1418) -> SolveErrorEnvelopeV1 {
1419 envelope(SolveErrorV1::new(code, message).at_path(path))
1420}
1421
1422fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1423 let root = require_object(value, "$")?;
1424
1425 validate_schema_version(root)?;
1429 validate_members(
1430 root,
1431 "$",
1432 &[
1433 "schema_version",
1434 "projectile",
1435 "rifle",
1436 "shot",
1437 "atmosphere",
1438 "wind",
1439 "solver",
1440 "effects",
1441 "sampling",
1442 "reticle",
1444 "corrections",
1446 ],
1447 &[
1448 "schema_version",
1449 "projectile",
1450 "rifle",
1451 "shot",
1452 "atmosphere",
1453 "wind",
1454 "solver",
1455 "effects",
1456 "sampling",
1457 ],
1458 )?;
1459
1460 validate_projectile(required_value(root, "projectile", "$")?)?;
1461 validate_rifle(required_value(root, "rifle", "$")?)?;
1462 validate_shot(required_value(root, "shot", "$")?)?;
1463 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1464 validate_wind(required_value(root, "wind", "$")?)?;
1465 validate_solver(required_value(root, "solver", "$")?)?;
1466 validate_effects(required_value(root, "effects", "$")?)?;
1467 validate_sampling(required_value(root, "sampling", "$")?)?;
1468 if let Some(reticle) = root.get("reticle") {
1469 validate_reticle(reticle)?;
1470 }
1471 if let Some(corrections) = root.get("corrections") {
1472 validate_corrections(corrections)?;
1473 }
1474 Ok(())
1475}
1476
1477fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1482 let path = "$.corrections";
1483 let object = require_object(value, path)?;
1484 validate_members(object, path, &["bc5d_table_path"], &[])?;
1485 if let Some(table_path) = object.get("bc5d_table_path") {
1486 if !table_path.is_string() {
1487 return Err(protocol_error(
1488 SolveErrorCodeV1::InvalidValue,
1489 "expected a string",
1490 "$.corrections.bc5d_table_path",
1491 ));
1492 }
1493 }
1494 Ok(())
1495}
1496
1497fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1504 let path = "$.reticle";
1505 let object = require_object(value, path)?;
1506 validate_members(
1507 object,
1508 path,
1509 &["range_m", "magnification", "description"],
1510 &["range_m", "magnification", "description"],
1511 )?;
1512 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1513 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1514 Ok(())
1515}
1516
1517fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1518 let value = required_value(root, "schema_version", "$")?;
1519 let version = if let Some(version) = value.as_i64() {
1520 i128::from(version)
1521 } else if let Some(version) = value.as_u64() {
1522 i128::from(version)
1523 } else {
1524 return Err(protocol_error(
1525 SolveErrorCodeV1::InvalidValue,
1526 "schema_version must be the integer 1",
1527 "$.schema_version",
1528 ));
1529 };
1530 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1531 return Err(protocol_error(
1532 SolveErrorCodeV1::UnsupportedSchemaVersion,
1533 format!(
1534 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1535 ),
1536 "$.schema_version",
1537 ));
1538 }
1539 Ok(())
1540}
1541
1542fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1543 let path = "$.projectile";
1544 let object = require_object(value, path)?;
1545 validate_members(
1546 object,
1547 path,
1548 &[
1549 "mass_kg",
1550 "diameter_m",
1551 "length_m",
1552 "drag_model",
1553 "ballistic_coefficient",
1554 ],
1555 &[
1556 "mass_kg",
1557 "diameter_m",
1558 "drag_model",
1559 "ballistic_coefficient",
1560 ],
1561 )?;
1562 validate_required_numbers(
1563 object,
1564 path,
1565 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1566 )?;
1567 validate_optional_number(object, path, "length_m")?;
1568 validate_string_enum(
1569 required_value(object, "drag_model", path)?,
1570 "$.projectile.drag_model",
1571 &DRAG_MODEL_WIRE_NAMES_V1,
1572 )
1573}
1574
1575fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1576 let path = "$.rifle";
1577 let object = require_object(value, path)?;
1578 validate_members(
1579 object,
1580 path,
1581 &[
1582 "muzzle_velocity_mps",
1583 "sight_height_m",
1584 "muzzle_height_m",
1585 "twist_rate_m_per_turn",
1586 "twist_direction",
1587 "sight_offset_lateral_m",
1588 ],
1589 &["muzzle_velocity_mps"],
1590 )?;
1591 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1592 validate_optional_numbers(
1593 object,
1594 path,
1595 &[
1596 "sight_height_m",
1597 "muzzle_height_m",
1598 "twist_rate_m_per_turn",
1599 "sight_offset_lateral_m",
1600 ],
1601 )?;
1602 if let Some(direction) = object.get("twist_direction") {
1603 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1604 }
1605 Ok(())
1606}
1607
1608fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1609 let path = "$.shot";
1610 let object = require_object(value, path)?;
1611 validate_members(
1612 object,
1613 path,
1614 &[
1615 "max_range_m",
1616 "zero_distance_m",
1617 "muzzle_angle_rad",
1618 "aim_azimuth_rad",
1619 "shot_azimuth_rad",
1620 "shooting_angle_rad",
1621 "cant_angle_rad",
1622 "target_height_m",
1623 "ground_threshold_m",
1624 "zero_poi_up_m",
1625 "zero_poi_right_m",
1626 "drops_reference",
1627 ],
1628 &["max_range_m"],
1629 )?;
1630 validate_required_numbers(object, path, &["max_range_m"])?;
1631 validate_optional_number(object, path, "zero_distance_m")?;
1632 validate_optional_number(object, path, "muzzle_angle_rad")?;
1633 validate_optional_numbers(
1634 object,
1635 path,
1636 &[
1637 "aim_azimuth_rad",
1638 "shot_azimuth_rad",
1639 "shooting_angle_rad",
1640 "cant_angle_rad",
1641 "target_height_m",
1642 "ground_threshold_m",
1643 "zero_poi_up_m",
1644 "zero_poi_right_m",
1645 ],
1646 )?;
1647 if let Some(reference) = object.get("drops_reference") {
1650 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1651 }
1652 Ok(())
1653}
1654
1655fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1656 let path = "$.atmosphere";
1657 let object = require_object(value, path)?;
1658 validate_members(
1659 object,
1660 path,
1661 &[
1662 "altitude_m",
1663 "temperature_k",
1664 "pressure_pa",
1665 "pressure_reference",
1666 "relative_humidity",
1667 "latitude_rad",
1668 ],
1669 &[],
1670 )?;
1671 validate_optional_numbers(
1672 object,
1673 path,
1674 &[
1675 "altitude_m",
1676 "temperature_k",
1677 "pressure_pa",
1678 "relative_humidity",
1679 "latitude_rad",
1680 ],
1681 )?;
1682 if let Some(reference) = object.get("pressure_reference") {
1688 validate_string_enum(
1689 reference,
1690 "$.atmosphere.pressure_reference",
1691 &["absolute", "qnh"],
1692 )?;
1693 }
1694 Ok(())
1695}
1696
1697fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1698 let path = "$.wind";
1699 let object = require_object(value, path)?;
1700 validate_members(
1701 object,
1702 path,
1703 &[
1704 "speed_mps",
1705 "direction_from_rad",
1706 "vertical_speed_mps",
1707 "segments",
1708 "wind_reference",
1709 ],
1710 &[],
1711 )?;
1712 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1713 validate_optional_number(object, path, field)?;
1714 }
1715 if let Some(reference) = object.get("wind_reference") {
1717 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1718 }
1719
1720 if let Some(segments) = object.get("segments") {
1721 let Some(segments) = segments.as_array() else {
1722 return Err(protocol_error(
1723 SolveErrorCodeV1::InvalidValue,
1724 "segments must be an array",
1725 "$.wind.segments",
1726 ));
1727 };
1728 for (index, segment) in segments.iter().enumerate() {
1729 let segment_path = format!("$.wind.segments[{index}]");
1730 let segment = require_object(segment, &segment_path)?;
1731 validate_members(
1732 segment,
1733 &segment_path,
1734 &[
1735 "until_distance_m",
1736 "speed_mps",
1737 "direction_from_rad",
1738 "vertical_speed_mps",
1739 ],
1740 &["until_distance_m", "speed_mps", "direction_from_rad"],
1741 )?;
1742 validate_required_numbers(
1743 segment,
1744 &segment_path,
1745 &["until_distance_m", "speed_mps", "direction_from_rad"],
1746 )?;
1747 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1748 }
1749 }
1750 Ok(())
1751}
1752
1753fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1754 let path = "$.solver";
1755 let object = require_object(value, path)?;
1756 validate_members(object, path, &["method", "time_step_s"], &[])?;
1757 validate_optional_number(object, path, "time_step_s")?;
1758 if let Some(method) = object.get("method") {
1759 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1760 }
1761 Ok(())
1762}
1763
1764pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1773 "magnus",
1774 "coriolis",
1775 "enhanced_spin_drift",
1776 "wind_shear_model",
1777 "aerodynamic_jump",
1778];
1779
1780fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1781 let path = "$.effects";
1782 let object = require_object(value, path)?;
1783 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1784 validate_optional_booleans(
1785 object,
1786 path,
1787 &[
1788 "magnus",
1789 "coriolis",
1790 "enhanced_spin_drift",
1791 "aerodynamic_jump",
1792 ],
1793 )?;
1794
1795 if let Some(model) = object.get("wind_shear_model") {
1799 validate_string_enum(
1800 model,
1801 "$.effects.wind_shear_model",
1802 &[
1803 "none",
1804 "logarithmic",
1805 "power_law",
1806 "ekman_spiral",
1807 "ekman",
1809 ],
1810 )?;
1811 }
1812
1813 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1814 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1815 {
1816 return Err(protocol_error(
1817 SolveErrorCodeV1::ConflictingFields,
1818 "magnus and enhanced_spin_drift cannot both be enabled",
1819 "$.effects",
1820 ));
1821 }
1822
1823 Ok(())
1824}
1825
1826fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1827 let path = "$.sampling";
1828 let object = require_object(value, path)?;
1829 validate_members(object, path, &["interval_m"], &[])?;
1830 validate_optional_number(object, path, "interval_m")
1831}
1832
1833fn require_object<'a>(
1834 value: &'a Value,
1835 path: &str,
1836) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1837 value
1838 .as_object()
1839 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1840}
1841
1842fn required_value<'a>(
1843 object: &'a Map<String, Value>,
1844 field: &str,
1845 parent_path: &str,
1846) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1847 object.get(field).ok_or_else(|| {
1848 protocol_error(
1849 SolveErrorCodeV1::MissingField,
1850 format!("missing required field `{field}`"),
1851 child_path(parent_path, field),
1852 )
1853 })
1854}
1855
1856fn validate_members(
1857 object: &Map<String, Value>,
1858 path: &str,
1859 allowed: &[&str],
1860 required: &[&str],
1861) -> Result<(), SolveErrorEnvelopeV1> {
1862 if let Some(field) = object
1863 .keys()
1864 .find(|field| !allowed.contains(&field.as_str()))
1865 {
1866 return Err(protocol_error(
1867 SolveErrorCodeV1::UnknownField,
1868 format!("unknown field `{field}`"),
1869 child_path(path, field),
1870 ));
1871 }
1872
1873 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1874 return Err(protocol_error(
1875 SolveErrorCodeV1::MissingField,
1876 format!("missing required field `{field}`"),
1877 child_path(path, field),
1878 ));
1879 }
1880 Ok(())
1881}
1882
1883fn validate_string_enum(
1884 value: &Value,
1885 path: &str,
1886 allowed: &[&str],
1887) -> Result<(), SolveErrorEnvelopeV1> {
1888 let Some(value) = value.as_str() else {
1889 return Err(protocol_error(
1890 SolveErrorCodeV1::InvalidValue,
1891 "expected a string enum value",
1892 path,
1893 ));
1894 };
1895 if !allowed.contains(&value) {
1896 return Err(protocol_error(
1897 SolveErrorCodeV1::InvalidValue,
1898 format!(
1899 "invalid value `{value}`; expected one of {}",
1900 allowed.join(", ")
1901 ),
1902 path,
1903 ));
1904 }
1905 Ok(())
1906}
1907
1908fn validate_required_numbers(
1909 object: &Map<String, Value>,
1910 parent_path: &str,
1911 fields: &[&str],
1912) -> Result<(), SolveErrorEnvelopeV1> {
1913 for field in fields {
1914 let value = required_value(object, field, parent_path)?;
1915 validate_number(value, &child_path(parent_path, field))?;
1916 }
1917 Ok(())
1918}
1919
1920fn validate_optional_numbers(
1921 object: &Map<String, Value>,
1922 parent_path: &str,
1923 fields: &[&str],
1924) -> Result<(), SolveErrorEnvelopeV1> {
1925 for field in fields {
1926 validate_optional_number(object, parent_path, field)?;
1927 }
1928 Ok(())
1929}
1930
1931fn validate_optional_number(
1932 object: &Map<String, Value>,
1933 parent_path: &str,
1934 field: &str,
1935) -> Result<(), SolveErrorEnvelopeV1> {
1936 if let Some(value) = object.get(field) {
1937 validate_number(value, &child_path(parent_path, field))?;
1938 }
1939 Ok(())
1940}
1941
1942fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1943 if value.is_number() {
1944 Ok(())
1945 } else {
1946 Err(protocol_error(
1947 SolveErrorCodeV1::InvalidValue,
1948 "expected a number",
1949 path,
1950 ))
1951 }
1952}
1953
1954fn validate_optional_booleans(
1955 object: &Map<String, Value>,
1956 parent_path: &str,
1957 fields: &[&str],
1958) -> Result<(), SolveErrorEnvelopeV1> {
1959 for field in fields {
1960 if let Some(value) = object.get(*field) {
1961 if !value.is_boolean() {
1962 return Err(protocol_error(
1963 SolveErrorCodeV1::InvalidValue,
1964 "expected a boolean",
1965 child_path(parent_path, field),
1966 ));
1967 }
1968 }
1969 }
1970 Ok(())
1971}
1972
1973fn child_path(parent: &str, field: &str) -> String {
1974 format!("{parent}.{field}")
1975}
1976
1977#[cfg(test)]
1979mod request_range_tests {
1980 use super::*;
1981
1982 fn valid_request_json() -> String {
1985 r#"{"schema_version":1,
1986 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1987 "drag_model":"G7","ballistic_coefficient":0.243},
1988 "rifle":{"muzzle_velocity_mps":823.0},
1989 "shot":{"max_range_m":1000.0},
1990 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1991 .to_string()
1992 }
1993
1994 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1995 decode_solve_request_v1(json).expect_err("request should have been rejected")
1996 }
1997
1998 #[test]
1999 fn an_ordinary_request_still_decodes() {
2000 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2001 }
2002
2003 #[test]
2009 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2010 let reproducer = r#"{"schema_version":1,
2011 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2012 "drag_model":"G7","ballistic_coefficient":1.2e2},
2013 "rifle":{"muzzle_velocity_mps":823.0},
2014 "shot":{"max_range_m":100.0},
2015 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2016
2017 let envelope = decode_err(reproducer);
2018 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2019 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2020 }
2021
2022 #[test]
2025 fn the_rejection_envelope_round_trips() {
2026 let envelope = decode_err(
2027 r#"{"schema_version":1,
2028 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2029 "drag_model":"G7","ballistic_coefficient":0.243},
2030 "rifle":{"muzzle_velocity_mps":823.0},
2031 "shot":{"max_range_m":100.0},
2032 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2033 );
2034 let encoded = serde_json::to_string(&envelope).expect("serialize");
2035 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2036 assert_eq!(decoded, envelope);
2037 }
2038
2039 #[test]
2040 fn each_bounded_field_reports_its_own_path() {
2041 for (field, bad_value, path) in [
2042 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2043 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2044 (
2045 "ballistic_coefficient",
2046 "1.0e6",
2047 "$.projectile.ballistic_coefficient",
2048 ),
2049 ("length_m", "1.0e-9", "$.projectile.length_m"),
2050 ] {
2051 let json = valid_request_json().replace(
2052 &format!("\"{field}\":{}", default_for(field)),
2053 &format!("\"{field}\":{bad_value}"),
2054 );
2055 let envelope = decode_err(&json);
2056 assert_eq!(
2057 envelope.error.path(),
2058 Some(path),
2059 "wrong path for {field}"
2060 );
2061 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2062 }
2063 }
2064
2065 fn default_for(field: &str) -> &'static str {
2066 match field {
2067 "mass_kg" => "0.01134",
2068 "diameter_m" => "0.00782",
2069 "ballistic_coefficient" => "0.243",
2070 "length_m" => "0.031",
2071 other => panic!("no default recorded for {other}"),
2072 }
2073 }
2074
2075 #[test]
2079 fn muzzle_velocity_is_deliberately_left_unbounded() {
2080 let json = valid_request_json().replace("823.0", "1.0e308");
2081 decode_solve_request_v1(&json)
2082 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2083 }
2084}