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(
300 default,
301 skip_serializing_if = "Option::is_none",
302 deserialize_with = "deserialize_present"
303 )]
304 pub twist_rate_m_per_turn: Option<f64>,
305 #[serde(
310 default,
311 skip_serializing_if = "Option::is_none",
312 deserialize_with = "deserialize_present"
313 )]
314 pub twist_direction: Option<TwistDirectionV1>,
315 #[serde(
323 default,
324 skip_serializing_if = "Option::is_none",
325 deserialize_with = "deserialize_present"
326 )]
327 pub sight_offset_lateral_m: Option<f64>,
328}
329
330#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
332#[serde(rename_all = "snake_case")]
333pub enum TwistDirectionV1 {
334 Left,
335 #[default]
336 Right,
337}
338
339#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct ShotV1 {
343 pub max_range_m: f64,
344 #[serde(
345 default,
346 skip_serializing_if = "Option::is_none",
347 deserialize_with = "deserialize_present"
348 )]
349 pub zero_distance_m: Option<f64>,
350 #[serde(
351 default,
352 skip_serializing_if = "Option::is_none",
353 deserialize_with = "deserialize_present"
354 )]
355 pub muzzle_angle_rad: Option<f64>,
356 #[serde(
357 default,
358 skip_serializing_if = "Option::is_none",
359 deserialize_with = "deserialize_present"
360 )]
361 pub aim_azimuth_rad: Option<f64>,
362 #[serde(
363 default,
364 skip_serializing_if = "Option::is_none",
365 deserialize_with = "deserialize_present"
366 )]
367 pub shot_azimuth_rad: Option<f64>,
368 #[serde(
369 default,
370 skip_serializing_if = "Option::is_none",
371 deserialize_with = "deserialize_present"
372 )]
373 pub shooting_angle_rad: Option<f64>,
374 #[serde(
375 default,
376 skip_serializing_if = "Option::is_none",
377 deserialize_with = "deserialize_present"
378 )]
379 pub cant_angle_rad: Option<f64>,
380 #[serde(
398 default,
399 skip_serializing_if = "Option::is_none",
400 deserialize_with = "deserialize_present"
401 )]
402 pub target_height_m: Option<f64>,
403 #[serde(
404 default,
405 skip_serializing_if = "Option::is_none",
406 deserialize_with = "deserialize_present"
407 )]
408 pub ground_threshold_m: Option<f64>,
409 #[serde(
415 default,
416 skip_serializing_if = "Option::is_none",
417 deserialize_with = "deserialize_present"
418 )]
419 pub zero_poi_up_m: Option<f64>,
420 #[serde(
424 default,
425 skip_serializing_if = "Option::is_none",
426 deserialize_with = "deserialize_present"
427 )]
428 pub zero_poi_right_m: Option<f64>,
429 #[serde(
437 default,
438 skip_serializing_if = "Option::is_none",
439 deserialize_with = "deserialize_present"
440 )]
441 pub drops_reference: Option<DropsReferenceV1>,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum DropsReferenceV1 {
448 #[default]
450 Los,
451 Target,
454}
455
456#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
471#[serde(deny_unknown_fields)]
472pub struct AtmosphereV1 {
473 #[serde(
474 default,
475 skip_serializing_if = "Option::is_none",
476 deserialize_with = "deserialize_present"
477 )]
478 pub altitude_m: Option<f64>,
479 #[serde(
482 default,
483 skip_serializing_if = "Option::is_none",
484 deserialize_with = "deserialize_present"
485 )]
486 pub temperature_k: Option<f64>,
487 #[serde(
493 default,
494 skip_serializing_if = "Option::is_none",
495 deserialize_with = "deserialize_present"
496 )]
497 pub pressure_pa: Option<f64>,
498 #[serde(
504 default,
505 skip_serializing_if = "Option::is_none",
506 deserialize_with = "deserialize_present"
507 )]
508 pub pressure_reference: Option<PressureReferenceV1>,
509 #[serde(
510 default,
511 skip_serializing_if = "Option::is_none",
512 deserialize_with = "deserialize_present"
513 )]
514 pub relative_humidity: Option<f64>,
515 #[serde(
516 default,
517 skip_serializing_if = "Option::is_none",
518 deserialize_with = "deserialize_present"
519 )]
520 pub latitude_rad: Option<f64>,
521}
522
523#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum PressureReferenceV1 {
529 #[default]
530 Absolute,
531 Qnh,
532}
533
534#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
539#[serde(deny_unknown_fields)]
540pub struct WindV1 {
541 #[serde(
542 default,
543 skip_serializing_if = "Option::is_none",
544 deserialize_with = "deserialize_present"
545 )]
546 pub speed_mps: Option<f64>,
547 #[serde(
548 default,
549 skip_serializing_if = "Option::is_none",
550 deserialize_with = "deserialize_present"
551 )]
552 pub direction_from_rad: Option<f64>,
553 #[serde(
554 default,
555 skip_serializing_if = "Option::is_none",
556 deserialize_with = "deserialize_present"
557 )]
558 pub vertical_speed_mps: Option<f64>,
559 #[serde(
560 default,
561 skip_serializing_if = "Option::is_none",
562 deserialize_with = "deserialize_present"
563 )]
564 pub segments: Option<Vec<WindSegmentV1>>,
565 #[serde(
578 default,
579 skip_serializing_if = "Option::is_none",
580 deserialize_with = "deserialize_present"
581 )]
582 pub wind_reference: Option<WindReferenceV1>,
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
587#[serde(rename_all = "snake_case")]
588pub enum WindReferenceV1 {
589 #[default]
591 Shooter,
592 Compass,
594}
595
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct WindSegmentV1 {
600 pub until_distance_m: f64,
601 pub speed_mps: f64,
602 pub direction_from_rad: f64,
603 #[serde(
604 default,
605 skip_serializing_if = "Option::is_none",
606 deserialize_with = "deserialize_present"
607 )]
608 pub vertical_speed_mps: Option<f64>,
609}
610
611#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub struct SolverV1 {
615 #[serde(
616 default,
617 skip_serializing_if = "Option::is_none",
618 deserialize_with = "deserialize_present"
619 )]
620 pub method: Option<SolverMethodV1>,
621 #[serde(
623 default,
624 skip_serializing_if = "Option::is_none",
625 deserialize_with = "deserialize_present"
626 )]
627 pub time_step_s: Option<f64>,
628}
629
630#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(rename_all = "snake_case")]
633pub enum SolverMethodV1 {
634 Euler,
635 Rk4,
636 #[default]
637 Rk45,
638}
639
640#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
642#[serde(deny_unknown_fields)]
643pub struct EffectsV1 {
644 #[serde(
656 default,
657 skip_serializing_if = "Option::is_none",
658 deserialize_with = "deserialize_present"
659 )]
660 pub magnus: Option<bool>,
661 #[serde(
665 default,
666 skip_serializing_if = "Option::is_none",
667 deserialize_with = "deserialize_present"
668 )]
669 pub coriolis: Option<bool>,
670 #[serde(
683 default,
684 skip_serializing_if = "Option::is_none",
685 deserialize_with = "deserialize_present"
686 )]
687 pub enhanced_spin_drift: Option<bool>,
688 #[serde(
698 default,
699 skip_serializing_if = "Option::is_none",
700 deserialize_with = "deserialize_present"
701 )]
702 pub wind_shear_model: Option<WindShearModelV1>,
703 #[serde(
720 default,
721 skip_serializing_if = "Option::is_none",
722 deserialize_with = "deserialize_present"
723 )]
724 pub aerodynamic_jump: Option<bool>,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
735#[serde(rename_all = "snake_case")]
736pub enum WindShearModelV1 {
737 #[default]
740 None,
741 Logarithmic,
743 PowerLaw,
745 #[serde(alias = "ekman")]
750 EkmanSpiral,
751}
752
753impl WindShearModelV1 {
754 pub fn as_engine_str(self) -> &'static str {
759 match self {
760 WindShearModelV1::None => "none",
761 WindShearModelV1::Logarithmic => "logarithmic",
762 WindShearModelV1::PowerLaw => "power_law",
763 WindShearModelV1::EkmanSpiral => "ekman_spiral",
764 }
765 }
766
767 pub fn is_enabled(self) -> bool {
774 !matches!(self, WindShearModelV1::None)
775 }
776}
777
778#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
780#[serde(deny_unknown_fields)]
781pub struct SamplingV1 {
782 #[serde(
783 default,
784 skip_serializing_if = "Option::is_none",
785 deserialize_with = "deserialize_present"
786 )]
787 pub interval_m: Option<f64>,
788}
789
790#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
796#[serde(deny_unknown_fields)]
797pub struct ResolvedSolveRequestV1 {
798 pub schema_version: SchemaVersionV1,
799 pub projectile: ResolvedProjectileV1,
800 pub rifle: ResolvedRifleV1,
801 pub shot: ResolvedShotV1,
802 pub atmosphere: ResolvedAtmosphereV1,
803 pub wind: ResolvedWindV1,
804 pub solver: ResolvedSolverV1,
805 pub effects: ResolvedEffectsV1,
806 pub sampling: ResolvedSamplingV1,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub reticle: Option<ReticleRequestV1>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub corrections: Option<CorrectionsV1>,
819}
820
821#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
823#[serde(deny_unknown_fields)]
824pub struct ResolvedProjectileV1 {
825 pub mass_kg: f64,
826 pub diameter_m: f64,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
828 pub length_m: Option<f64>,
829 pub drag_model: DragModelV1,
830 pub ballistic_coefficient: f64,
831}
832
833#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
835#[serde(deny_unknown_fields)]
836pub struct ResolvedRifleV1 {
837 pub muzzle_velocity_mps: f64,
838 pub sight_height_m: f64,
839 pub muzzle_height_m: f64,
840 pub twist_rate_m_per_turn: f64,
841 pub twist_direction: TwistDirectionV1,
842 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub sight_offset_lateral_m: Option<f64>,
846}
847
848#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
860#[serde(deny_unknown_fields)]
861pub struct ResolvedShotV1 {
862 pub max_range_m: f64,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub zero_distance_m: Option<f64>,
865 pub muzzle_angle_rad: f64,
866 pub aim_azimuth_rad: f64,
867 pub shot_azimuth_rad: f64,
868 pub shooting_angle_rad: f64,
869 pub cant_angle_rad: f64,
870 pub target_height_m: f64,
871 pub ground_threshold_m: f64,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
875 pub zero_poi_up_m: Option<f64>,
876 #[serde(default, skip_serializing_if = "Option::is_none")]
879 pub zero_poi_right_m: Option<f64>,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub drops_reference: Option<DropsReferenceV1>,
884}
885
886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct ResolvedAtmosphereV1 {
890 pub altitude_m: f64,
891 pub temperature_k: f64,
892 pub pressure_pa: f64,
893 pub relative_humidity: f64,
894 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub latitude_rad: Option<f64>,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub pressure_reference: Option<PressureReferenceV1>,
900}
901
902#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
907#[serde(untagged)]
908pub enum ResolvedWindV1 {
909 Constant(ResolvedConstantWindV1),
910 Segmented(ResolvedSegmentedWindV1),
911}
912
913#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct ResolvedConstantWindV1 {
917 pub speed_mps: f64,
918 pub direction_from_rad: f64,
919 pub vertical_speed_mps: f64,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub wind_reference: Option<WindReferenceV1>,
925}
926
927#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
929#[serde(deny_unknown_fields)]
930pub struct ResolvedSegmentedWindV1 {
931 pub segments: Vec<ResolvedWindSegmentV1>,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub wind_reference: Option<WindReferenceV1>,
937}
938
939#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
941#[serde(deny_unknown_fields)]
942pub struct ResolvedWindSegmentV1 {
943 pub until_distance_m: f64,
944 pub speed_mps: f64,
945 pub direction_from_rad: f64,
946 pub vertical_speed_mps: f64,
947}
948
949#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
951#[serde(deny_unknown_fields)]
952pub struct ResolvedSolverV1 {
953 pub method: SolverMethodV1,
954 pub time_step_s: f64,
955}
956
957#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(deny_unknown_fields)]
960pub struct ResolvedEffectsV1 {
961 pub magnus: bool,
962 pub coriolis: bool,
963 pub enhanced_spin_drift: bool,
964 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub wind_shear_model: Option<WindShearModelV1>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub aerodynamic_jump: Option<bool>,
980}
981
982#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
984#[serde(deny_unknown_fields)]
985pub struct ResolvedSamplingV1 {
986 pub interval_m: f64,
987}
988
989#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
991#[serde(deny_unknown_fields)]
992pub struct SolveSuccessV1 {
993 pub schema_version: SchemaVersionV1,
994 pub engine_version: String,
995 pub status: SuccessStatusV1,
996 pub resolved_request: ResolvedSolveRequestV1,
997 #[serde(default)]
998 pub assumptions: Vec<SolveNoticeV1>,
999 #[serde(default)]
1000 pub warnings: Vec<SolveNoticeV1>,
1001 pub summary: SolveSummaryV1,
1002 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
1003 pub samples: Vec<TrajectorySampleV1>,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub reticle_hold: Option<ReticleHoldV1>,
1009}
1010
1011fn serialize_solve_samples_v1<S>(
1012 samples: &[TrajectorySampleV1],
1013 serializer: S,
1014) -> Result<S::Ok, S::Error>
1015where
1016 S: Serializer,
1017{
1018 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
1019 return Err(serde::ser::Error::custom(format_args!(
1020 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1021 samples.len()
1022 )));
1023 }
1024 samples.serialize(serializer)
1025}
1026
1027impl SolveSuccessV1 {
1028 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
1034 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
1035 return Ok(());
1036 }
1037
1038 Err(SolveErrorEnvelopeV1::new(
1039 SolveErrorV1::new(
1040 SolveErrorCodeV1::ResourceLimit,
1041 format!(
1042 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
1043 self.samples.len()
1044 ),
1045 )
1046 .at_path("$.sampling.interval_m"),
1047 ))
1048 }
1049}
1050
1051#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1053#[serde(rename_all = "snake_case")]
1054pub enum SuccessStatusV1 {
1055 Ok,
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1060#[serde(deny_unknown_fields)]
1061pub struct SolveNoticeV1 {
1062 pub code: String,
1063 pub message: String,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub path: Option<String>,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1070#[serde(deny_unknown_fields)]
1071pub struct SolveSummaryV1 {
1072 pub actual_range_m: f64,
1073 pub maximum_height_m: f64,
1076 pub time_of_flight_s: f64,
1077 pub terminal_speed_mps: f64,
1078 pub terminal_energy_j: f64,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub stability_factor: Option<f64>,
1083 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub spin_drift_m: Option<f64>,
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub equivalent_horizontal_range_m: Option<f64>,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub aerodynamic_jump_moa: Option<f64>,
1110 pub termination: TerminationReasonV1,
1111}
1112
1113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1115#[serde(rename_all = "snake_case")]
1116pub enum TerminationReasonV1 {
1117 MaxRange,
1118 GroundThreshold,
1119 TimeLimit,
1120 VelocityFloor,
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1125#[serde(deny_unknown_fields)]
1126pub struct TrajectorySampleV1 {
1127 pub distance_m: f64,
1128 pub time_s: f64,
1129 pub speed_mps: f64,
1130 pub energy_j: f64,
1131 pub drop_m: f64,
1133 pub windage_m: f64,
1135 pub mach: f64,
1136 #[serde(default)]
1137 pub flags: Vec<SampleFlagV1>,
1138}
1139
1140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1142#[serde(rename_all = "snake_case")]
1143pub enum SampleFlagV1 {
1144 Transonic,
1145 Subsonic,
1146 Terminal,
1147 GroundThreshold,
1148}
1149
1150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1152#[serde(deny_unknown_fields)]
1153pub struct SolveErrorEnvelopeV1 {
1154 pub schema_version: SchemaVersionV1,
1155 pub status: ErrorStatusV1,
1156 pub error: SolveErrorV1,
1157}
1158
1159impl SolveErrorEnvelopeV1 {
1160 pub fn new(error: SolveErrorV1) -> Self {
1162 Self {
1163 schema_version: SchemaVersionV1,
1164 status: ErrorStatusV1::Error,
1165 error,
1166 }
1167 }
1168}
1169
1170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1172#[serde(rename_all = "snake_case")]
1173pub enum ErrorStatusV1 {
1174 Error,
1175}
1176
1177#[derive(Debug, Clone, PartialEq, Eq)]
1183pub struct SolveErrorV1 {
1184 pub code: SolveErrorCodeV1,
1185 pub message: String,
1186 location: SolveErrorLocationV1,
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq)]
1190enum SolveErrorLocationV1 {
1191 None,
1192 Path(String),
1193 Source {
1194 line: NonZeroUsize,
1195 column: NonZeroUsize,
1196 },
1197}
1198
1199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1201pub enum SolveErrorLocationErrorV1 {
1202 ZeroLine,
1203 ZeroColumn,
1204}
1205
1206impl fmt::Display for SolveErrorLocationErrorV1 {
1207 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1208 match self {
1209 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1210 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1211 }
1212 }
1213}
1214
1215impl std::error::Error for SolveErrorLocationErrorV1 {}
1216
1217impl SolveErrorV1 {
1218 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1220 Self {
1221 code,
1222 message: message.into(),
1223 location: SolveErrorLocationV1::None,
1224 }
1225 }
1226
1227 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1229 self.location = SolveErrorLocationV1::Path(path.into());
1230 self
1231 }
1232
1233 pub fn at_location(
1235 mut self,
1236 line: usize,
1237 column: usize,
1238 ) -> Result<Self, SolveErrorLocationErrorV1> {
1239 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1240 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1241 self.location = SolveErrorLocationV1::Source { line, column };
1242 Ok(self)
1243 }
1244
1245 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1250 self.location = SolveErrorLocationV1::Source {
1251 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1252 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1253 };
1254 self
1255 }
1256
1257 pub fn path(&self) -> Option<&str> {
1259 match &self.location {
1260 SolveErrorLocationV1::Path(path) => Some(path),
1261 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1262 }
1263 }
1264
1265 pub fn line(&self) -> Option<usize> {
1267 match &self.location {
1268 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1269 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1270 }
1271 }
1272
1273 pub fn column(&self) -> Option<usize> {
1275 match &self.location {
1276 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1277 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1278 }
1279 }
1280}
1281
1282impl Serialize for SolveErrorV1 {
1283 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1284 where
1285 S: Serializer,
1286 {
1287 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1288 state.serialize_field("code", &self.code)?;
1289 state.serialize_field("message", &self.message)?;
1290 state.serialize_field("path", &self.path())?;
1291 state.serialize_field("line", &self.line())?;
1292 state.serialize_field("column", &self.column())?;
1293 state.end()
1294 }
1295}
1296
1297impl<'de> Deserialize<'de> for SolveErrorV1 {
1298 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1299 where
1300 D: Deserializer<'de>,
1301 {
1302 #[derive(Deserialize)]
1303 #[serde(deny_unknown_fields)]
1304 struct SolveErrorWireV1 {
1305 code: SolveErrorCodeV1,
1306 message: String,
1307 path: Option<String>,
1308 line: Option<usize>,
1309 column: Option<usize>,
1310 }
1311
1312 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1313 let location = match (wire.path, wire.line, wire.column) {
1314 (None, None, None) => SolveErrorLocationV1::None,
1315 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1316 (None, Some(line), Some(column)) => {
1317 let line = NonZeroUsize::new(line)
1318 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1319 let column = NonZeroUsize::new(column)
1320 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1321 SolveErrorLocationV1::Source { line, column }
1322 }
1323 _ => {
1324 return Err(de::Error::custom(
1325 "error location must contain either path or both line and column",
1326 ));
1327 }
1328 };
1329
1330 Ok(Self {
1331 code: wire.code,
1332 message: wire.message,
1333 location,
1334 })
1335 }
1336}
1337
1338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1340#[serde(rename_all = "snake_case")]
1341pub enum SolveErrorCodeV1 {
1342 InvalidJson,
1343 UnsupportedSchemaVersion,
1344 UnknownField,
1345 MissingField,
1346 InvalidValue,
1347 ConflictingFields,
1348 ResourceLimit,
1349 SolveFailed,
1350 IoError,
1351 InternalError,
1352}
1353
1354pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1361 let value: Value = serde_json::from_str(input).map_err(|error| {
1362 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1363 .at_parser_location(error.line(), error.column());
1364 envelope(error)
1365 })?;
1366
1367 validate_request_shape(&value)?;
1368
1369 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1370 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1371 })?;
1372
1373 validate_request_ranges(&request)?;
1374
1375 Ok(request)
1376}
1377
1378mod limits {
1391 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1395 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1397 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1399 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1401
1402 }
1409
1410fn require_range(
1411 value: f64,
1412 (min, max): (f64, f64),
1413 path: &str,
1414) -> Result<(), SolveErrorEnvelopeV1> {
1415 if !value.is_finite() {
1416 return Err(protocol_error(
1417 SolveErrorCodeV1::InvalidValue,
1418 format!("{path} must be a finite number"),
1419 path,
1420 ));
1421 }
1422 if value < min || value > max {
1423 return Err(protocol_error(
1424 SolveErrorCodeV1::InvalidValue,
1425 format!("{path} must be between {min} and {max}, got {value}"),
1426 path,
1427 ));
1428 }
1429 Ok(())
1430}
1431
1432fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1437 require_range(
1438 request.projectile.mass_kg,
1439 limits::MASS_KG,
1440 "$.projectile.mass_kg",
1441 )?;
1442 require_range(
1443 request.projectile.diameter_m,
1444 limits::DIAMETER_M,
1445 "$.projectile.diameter_m",
1446 )?;
1447 require_range(
1448 request.projectile.ballistic_coefficient,
1449 limits::BALLISTIC_COEFFICIENT,
1450 "$.projectile.ballistic_coefficient",
1451 )?;
1452 if let Some(length_m) = request.projectile.length_m {
1453 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1454 }
1455 Ok(())
1456}
1457
1458fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1459 SolveErrorEnvelopeV1::new(error)
1460}
1461
1462fn protocol_error(
1463 code: SolveErrorCodeV1,
1464 message: impl Into<String>,
1465 path: impl Into<String>,
1466) -> SolveErrorEnvelopeV1 {
1467 envelope(SolveErrorV1::new(code, message).at_path(path))
1468}
1469
1470fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1471 let root = require_object(value, "$")?;
1472
1473 validate_schema_version(root)?;
1477 validate_members(
1478 root,
1479 "$",
1480 &[
1481 "schema_version",
1482 "projectile",
1483 "rifle",
1484 "shot",
1485 "atmosphere",
1486 "wind",
1487 "solver",
1488 "effects",
1489 "sampling",
1490 "reticle",
1492 "corrections",
1494 ],
1495 &[
1496 "schema_version",
1497 "projectile",
1498 "rifle",
1499 "shot",
1500 "atmosphere",
1501 "wind",
1502 "solver",
1503 "effects",
1504 "sampling",
1505 ],
1506 )?;
1507
1508 validate_projectile(required_value(root, "projectile", "$")?)?;
1509 validate_rifle(required_value(root, "rifle", "$")?)?;
1510 validate_shot(required_value(root, "shot", "$")?)?;
1511 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1512 validate_wind(required_value(root, "wind", "$")?)?;
1513 validate_solver(required_value(root, "solver", "$")?)?;
1514 validate_effects(required_value(root, "effects", "$")?)?;
1515 validate_sampling(required_value(root, "sampling", "$")?)?;
1516 if let Some(reticle) = root.get("reticle") {
1517 validate_reticle(reticle)?;
1518 }
1519 if let Some(corrections) = root.get("corrections") {
1520 validate_corrections(corrections)?;
1521 }
1522 Ok(())
1523}
1524
1525fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1530 let path = "$.corrections";
1531 let object = require_object(value, path)?;
1532 validate_members(object, path, &["bc5d_table_path"], &[])?;
1533 if let Some(table_path) = object.get("bc5d_table_path") {
1534 if !table_path.is_string() {
1535 return Err(protocol_error(
1536 SolveErrorCodeV1::InvalidValue,
1537 "expected a string",
1538 "$.corrections.bc5d_table_path",
1539 ));
1540 }
1541 }
1542 Ok(())
1543}
1544
1545fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1552 let path = "$.reticle";
1553 let object = require_object(value, path)?;
1554 validate_members(
1555 object,
1556 path,
1557 &["range_m", "magnification", "description"],
1558 &["range_m", "magnification", "description"],
1559 )?;
1560 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1561 require_object(
1562 required_value(object, "description", path)?,
1563 "$.reticle.description",
1564 )?;
1565 Ok(())
1566}
1567
1568fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1569 let value = required_value(root, "schema_version", "$")?;
1570 let version = if let Some(version) = value.as_i64() {
1571 i128::from(version)
1572 } else if let Some(version) = value.as_u64() {
1573 i128::from(version)
1574 } else {
1575 return Err(protocol_error(
1576 SolveErrorCodeV1::InvalidValue,
1577 "schema_version must be the integer 1",
1578 "$.schema_version",
1579 ));
1580 };
1581 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1582 return Err(protocol_error(
1583 SolveErrorCodeV1::UnsupportedSchemaVersion,
1584 format!(
1585 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1586 ),
1587 "$.schema_version",
1588 ));
1589 }
1590 Ok(())
1591}
1592
1593fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1594 let path = "$.projectile";
1595 let object = require_object(value, path)?;
1596 validate_members(
1597 object,
1598 path,
1599 &[
1600 "mass_kg",
1601 "diameter_m",
1602 "length_m",
1603 "drag_model",
1604 "ballistic_coefficient",
1605 ],
1606 &[
1607 "mass_kg",
1608 "diameter_m",
1609 "drag_model",
1610 "ballistic_coefficient",
1611 ],
1612 )?;
1613 validate_required_numbers(
1614 object,
1615 path,
1616 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1617 )?;
1618 validate_optional_number(object, path, "length_m")?;
1619 validate_string_enum(
1620 required_value(object, "drag_model", path)?,
1621 "$.projectile.drag_model",
1622 &DRAG_MODEL_WIRE_NAMES_V1,
1623 )
1624}
1625
1626fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1627 let path = "$.rifle";
1628 let object = require_object(value, path)?;
1629 validate_members(
1630 object,
1631 path,
1632 &[
1633 "muzzle_velocity_mps",
1634 "sight_height_m",
1635 "muzzle_height_m",
1636 "twist_rate_m_per_turn",
1637 "twist_direction",
1638 "sight_offset_lateral_m",
1639 ],
1640 &["muzzle_velocity_mps"],
1641 )?;
1642 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1643 validate_optional_numbers(
1644 object,
1645 path,
1646 &[
1647 "sight_height_m",
1648 "muzzle_height_m",
1649 "twist_rate_m_per_turn",
1650 "sight_offset_lateral_m",
1651 ],
1652 )?;
1653 if let Some(direction) = object.get("twist_direction") {
1654 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1655 }
1656 Ok(())
1657}
1658
1659fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1660 let path = "$.shot";
1661 let object = require_object(value, path)?;
1662 validate_members(
1663 object,
1664 path,
1665 &[
1666 "max_range_m",
1667 "zero_distance_m",
1668 "muzzle_angle_rad",
1669 "aim_azimuth_rad",
1670 "shot_azimuth_rad",
1671 "shooting_angle_rad",
1672 "cant_angle_rad",
1673 "target_height_m",
1674 "ground_threshold_m",
1675 "zero_poi_up_m",
1676 "zero_poi_right_m",
1677 "drops_reference",
1678 ],
1679 &["max_range_m"],
1680 )?;
1681 validate_required_numbers(object, path, &["max_range_m"])?;
1682 validate_optional_number(object, path, "zero_distance_m")?;
1683 validate_optional_number(object, path, "muzzle_angle_rad")?;
1684 validate_optional_numbers(
1685 object,
1686 path,
1687 &[
1688 "aim_azimuth_rad",
1689 "shot_azimuth_rad",
1690 "shooting_angle_rad",
1691 "cant_angle_rad",
1692 "target_height_m",
1693 "ground_threshold_m",
1694 "zero_poi_up_m",
1695 "zero_poi_right_m",
1696 ],
1697 )?;
1698 if let Some(reference) = object.get("drops_reference") {
1701 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1702 }
1703 Ok(())
1704}
1705
1706fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1707 let path = "$.atmosphere";
1708 let object = require_object(value, path)?;
1709 validate_members(
1710 object,
1711 path,
1712 &[
1713 "altitude_m",
1714 "temperature_k",
1715 "pressure_pa",
1716 "pressure_reference",
1717 "relative_humidity",
1718 "latitude_rad",
1719 ],
1720 &[],
1721 )?;
1722 validate_optional_numbers(
1723 object,
1724 path,
1725 &[
1726 "altitude_m",
1727 "temperature_k",
1728 "pressure_pa",
1729 "relative_humidity",
1730 "latitude_rad",
1731 ],
1732 )?;
1733 if let Some(reference) = object.get("pressure_reference") {
1739 validate_string_enum(
1740 reference,
1741 "$.atmosphere.pressure_reference",
1742 &["absolute", "qnh"],
1743 )?;
1744 }
1745 Ok(())
1746}
1747
1748fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1749 let path = "$.wind";
1750 let object = require_object(value, path)?;
1751 validate_members(
1752 object,
1753 path,
1754 &[
1755 "speed_mps",
1756 "direction_from_rad",
1757 "vertical_speed_mps",
1758 "segments",
1759 "wind_reference",
1760 ],
1761 &[],
1762 )?;
1763 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1764 validate_optional_number(object, path, field)?;
1765 }
1766 if let Some(reference) = object.get("wind_reference") {
1768 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1769 }
1770
1771 if let Some(segments) = object.get("segments") {
1772 let Some(segments) = segments.as_array() else {
1773 return Err(protocol_error(
1774 SolveErrorCodeV1::InvalidValue,
1775 "segments must be an array",
1776 "$.wind.segments",
1777 ));
1778 };
1779 for (index, segment) in segments.iter().enumerate() {
1780 let segment_path = format!("$.wind.segments[{index}]");
1781 let segment = require_object(segment, &segment_path)?;
1782 validate_members(
1783 segment,
1784 &segment_path,
1785 &[
1786 "until_distance_m",
1787 "speed_mps",
1788 "direction_from_rad",
1789 "vertical_speed_mps",
1790 ],
1791 &["until_distance_m", "speed_mps", "direction_from_rad"],
1792 )?;
1793 validate_required_numbers(
1794 segment,
1795 &segment_path,
1796 &["until_distance_m", "speed_mps", "direction_from_rad"],
1797 )?;
1798 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1799 }
1800 }
1801 Ok(())
1802}
1803
1804fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1805 let path = "$.solver";
1806 let object = require_object(value, path)?;
1807 validate_members(object, path, &["method", "time_step_s"], &[])?;
1808 validate_optional_number(object, path, "time_step_s")?;
1809 if let Some(method) = object.get("method") {
1810 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1811 }
1812 Ok(())
1813}
1814
1815pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1824 "magnus",
1825 "coriolis",
1826 "enhanced_spin_drift",
1827 "wind_shear_model",
1828 "aerodynamic_jump",
1829];
1830
1831fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1832 let path = "$.effects";
1833 let object = require_object(value, path)?;
1834 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1835 validate_optional_booleans(
1836 object,
1837 path,
1838 &[
1839 "magnus",
1840 "coriolis",
1841 "enhanced_spin_drift",
1842 "aerodynamic_jump",
1843 ],
1844 )?;
1845
1846 if let Some(model) = object.get("wind_shear_model") {
1850 validate_string_enum(
1851 model,
1852 "$.effects.wind_shear_model",
1853 &[
1854 "none",
1855 "logarithmic",
1856 "power_law",
1857 "ekman_spiral",
1858 "ekman",
1860 ],
1861 )?;
1862 }
1863
1864 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1865 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1866 {
1867 return Err(protocol_error(
1868 SolveErrorCodeV1::ConflictingFields,
1869 "magnus and enhanced_spin_drift cannot both be enabled",
1870 "$.effects",
1871 ));
1872 }
1873
1874 Ok(())
1875}
1876
1877fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1878 let path = "$.sampling";
1879 let object = require_object(value, path)?;
1880 validate_members(object, path, &["interval_m"], &[])?;
1881 validate_optional_number(object, path, "interval_m")
1882}
1883
1884fn require_object<'a>(
1885 value: &'a Value,
1886 path: &str,
1887) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1888 value
1889 .as_object()
1890 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1891}
1892
1893fn required_value<'a>(
1894 object: &'a Map<String, Value>,
1895 field: &str,
1896 parent_path: &str,
1897) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1898 object.get(field).ok_or_else(|| {
1899 protocol_error(
1900 SolveErrorCodeV1::MissingField,
1901 format!("missing required field `{field}`"),
1902 child_path(parent_path, field),
1903 )
1904 })
1905}
1906
1907fn validate_members(
1908 object: &Map<String, Value>,
1909 path: &str,
1910 allowed: &[&str],
1911 required: &[&str],
1912) -> Result<(), SolveErrorEnvelopeV1> {
1913 if let Some(field) = object
1914 .keys()
1915 .find(|field| !allowed.contains(&field.as_str()))
1916 {
1917 return Err(protocol_error(
1918 SolveErrorCodeV1::UnknownField,
1919 format!("unknown field `{field}`"),
1920 child_path(path, field),
1921 ));
1922 }
1923
1924 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1925 return Err(protocol_error(
1926 SolveErrorCodeV1::MissingField,
1927 format!("missing required field `{field}`"),
1928 child_path(path, field),
1929 ));
1930 }
1931 Ok(())
1932}
1933
1934fn validate_string_enum(
1935 value: &Value,
1936 path: &str,
1937 allowed: &[&str],
1938) -> Result<(), SolveErrorEnvelopeV1> {
1939 let Some(value) = value.as_str() else {
1940 return Err(protocol_error(
1941 SolveErrorCodeV1::InvalidValue,
1942 "expected a string enum value",
1943 path,
1944 ));
1945 };
1946 if !allowed.contains(&value) {
1947 return Err(protocol_error(
1948 SolveErrorCodeV1::InvalidValue,
1949 format!(
1950 "invalid value `{value}`; expected one of {}",
1951 allowed.join(", ")
1952 ),
1953 path,
1954 ));
1955 }
1956 Ok(())
1957}
1958
1959fn validate_required_numbers(
1960 object: &Map<String, Value>,
1961 parent_path: &str,
1962 fields: &[&str],
1963) -> Result<(), SolveErrorEnvelopeV1> {
1964 for field in fields {
1965 let value = required_value(object, field, parent_path)?;
1966 validate_number(value, &child_path(parent_path, field))?;
1967 }
1968 Ok(())
1969}
1970
1971fn validate_optional_numbers(
1972 object: &Map<String, Value>,
1973 parent_path: &str,
1974 fields: &[&str],
1975) -> Result<(), SolveErrorEnvelopeV1> {
1976 for field in fields {
1977 validate_optional_number(object, parent_path, field)?;
1978 }
1979 Ok(())
1980}
1981
1982fn validate_optional_number(
1983 object: &Map<String, Value>,
1984 parent_path: &str,
1985 field: &str,
1986) -> Result<(), SolveErrorEnvelopeV1> {
1987 if let Some(value) = object.get(field) {
1988 validate_number(value, &child_path(parent_path, field))?;
1989 }
1990 Ok(())
1991}
1992
1993fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1994 if value.is_number() {
1995 Ok(())
1996 } else {
1997 Err(protocol_error(
1998 SolveErrorCodeV1::InvalidValue,
1999 "expected a number",
2000 path,
2001 ))
2002 }
2003}
2004
2005fn validate_optional_booleans(
2006 object: &Map<String, Value>,
2007 parent_path: &str,
2008 fields: &[&str],
2009) -> Result<(), SolveErrorEnvelopeV1> {
2010 for field in fields {
2011 if let Some(value) = object.get(*field) {
2012 if !value.is_boolean() {
2013 return Err(protocol_error(
2014 SolveErrorCodeV1::InvalidValue,
2015 "expected a boolean",
2016 child_path(parent_path, field),
2017 ));
2018 }
2019 }
2020 }
2021 Ok(())
2022}
2023
2024fn child_path(parent: &str, field: &str) -> String {
2025 format!("{parent}.{field}")
2026}
2027
2028#[cfg(test)]
2030mod request_range_tests {
2031 use super::*;
2032
2033 fn valid_request_json() -> String {
2036 r#"{"schema_version":1,
2037 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2038 "drag_model":"G7","ballistic_coefficient":0.243},
2039 "rifle":{"muzzle_velocity_mps":823.0},
2040 "shot":{"max_range_m":1000.0},
2041 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2042 .to_string()
2043 }
2044
2045 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2046 decode_solve_request_v1(json).expect_err("request should have been rejected")
2047 }
2048
2049 #[test]
2050 fn an_ordinary_request_still_decodes() {
2051 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2052 }
2053
2054 #[test]
2060 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2061 let reproducer = r#"{"schema_version":1,
2062 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2063 "drag_model":"G7","ballistic_coefficient":1.2e2},
2064 "rifle":{"muzzle_velocity_mps":823.0},
2065 "shot":{"max_range_m":100.0},
2066 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2067
2068 let envelope = decode_err(reproducer);
2069 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2070 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2071 }
2072
2073 #[test]
2076 fn the_rejection_envelope_round_trips() {
2077 let envelope = decode_err(
2078 r#"{"schema_version":1,
2079 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2080 "drag_model":"G7","ballistic_coefficient":0.243},
2081 "rifle":{"muzzle_velocity_mps":823.0},
2082 "shot":{"max_range_m":100.0},
2083 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2084 );
2085 let encoded = serde_json::to_string(&envelope).expect("serialize");
2086 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2087 assert_eq!(decoded, envelope);
2088 }
2089
2090 #[test]
2091 fn each_bounded_field_reports_its_own_path() {
2092 for (field, bad_value, path) in [
2093 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2094 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2095 (
2096 "ballistic_coefficient",
2097 "1.0e6",
2098 "$.projectile.ballistic_coefficient",
2099 ),
2100 ("length_m", "1.0e-9", "$.projectile.length_m"),
2101 ] {
2102 let json = valid_request_json().replace(
2103 &format!("\"{field}\":{}", default_for(field)),
2104 &format!("\"{field}\":{bad_value}"),
2105 );
2106 let envelope = decode_err(&json);
2107 assert_eq!(envelope.error.path(), Some(path), "wrong path for {field}");
2108 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2109 }
2110 }
2111
2112 fn default_for(field: &str) -> &'static str {
2113 match field {
2114 "mass_kg" => "0.01134",
2115 "diameter_m" => "0.00782",
2116 "ballistic_coefficient" => "0.243",
2117 "length_m" => "0.031",
2118 other => panic!("no default recorded for {other}"),
2119 }
2120 }
2121
2122 #[test]
2126 fn muzzle_velocity_is_deliberately_left_unbounded() {
2127 let json = valid_request_json().replace("823.0", "1.0e308");
2128 decode_solve_request_v1(&json)
2129 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2130 }
2131}