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(required_value(object, "description", path)?, "$.reticle.description")?;
1562 Ok(())
1563}
1564
1565fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1566 let value = required_value(root, "schema_version", "$")?;
1567 let version = if let Some(version) = value.as_i64() {
1568 i128::from(version)
1569 } else if let Some(version) = value.as_u64() {
1570 i128::from(version)
1571 } else {
1572 return Err(protocol_error(
1573 SolveErrorCodeV1::InvalidValue,
1574 "schema_version must be the integer 1",
1575 "$.schema_version",
1576 ));
1577 };
1578 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1579 return Err(protocol_error(
1580 SolveErrorCodeV1::UnsupportedSchemaVersion,
1581 format!(
1582 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1583 ),
1584 "$.schema_version",
1585 ));
1586 }
1587 Ok(())
1588}
1589
1590fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1591 let path = "$.projectile";
1592 let object = require_object(value, path)?;
1593 validate_members(
1594 object,
1595 path,
1596 &[
1597 "mass_kg",
1598 "diameter_m",
1599 "length_m",
1600 "drag_model",
1601 "ballistic_coefficient",
1602 ],
1603 &[
1604 "mass_kg",
1605 "diameter_m",
1606 "drag_model",
1607 "ballistic_coefficient",
1608 ],
1609 )?;
1610 validate_required_numbers(
1611 object,
1612 path,
1613 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1614 )?;
1615 validate_optional_number(object, path, "length_m")?;
1616 validate_string_enum(
1617 required_value(object, "drag_model", path)?,
1618 "$.projectile.drag_model",
1619 &DRAG_MODEL_WIRE_NAMES_V1,
1620 )
1621}
1622
1623fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1624 let path = "$.rifle";
1625 let object = require_object(value, path)?;
1626 validate_members(
1627 object,
1628 path,
1629 &[
1630 "muzzle_velocity_mps",
1631 "sight_height_m",
1632 "muzzle_height_m",
1633 "twist_rate_m_per_turn",
1634 "twist_direction",
1635 "sight_offset_lateral_m",
1636 ],
1637 &["muzzle_velocity_mps"],
1638 )?;
1639 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1640 validate_optional_numbers(
1641 object,
1642 path,
1643 &[
1644 "sight_height_m",
1645 "muzzle_height_m",
1646 "twist_rate_m_per_turn",
1647 "sight_offset_lateral_m",
1648 ],
1649 )?;
1650 if let Some(direction) = object.get("twist_direction") {
1651 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1652 }
1653 Ok(())
1654}
1655
1656fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1657 let path = "$.shot";
1658 let object = require_object(value, path)?;
1659 validate_members(
1660 object,
1661 path,
1662 &[
1663 "max_range_m",
1664 "zero_distance_m",
1665 "muzzle_angle_rad",
1666 "aim_azimuth_rad",
1667 "shot_azimuth_rad",
1668 "shooting_angle_rad",
1669 "cant_angle_rad",
1670 "target_height_m",
1671 "ground_threshold_m",
1672 "zero_poi_up_m",
1673 "zero_poi_right_m",
1674 "drops_reference",
1675 ],
1676 &["max_range_m"],
1677 )?;
1678 validate_required_numbers(object, path, &["max_range_m"])?;
1679 validate_optional_number(object, path, "zero_distance_m")?;
1680 validate_optional_number(object, path, "muzzle_angle_rad")?;
1681 validate_optional_numbers(
1682 object,
1683 path,
1684 &[
1685 "aim_azimuth_rad",
1686 "shot_azimuth_rad",
1687 "shooting_angle_rad",
1688 "cant_angle_rad",
1689 "target_height_m",
1690 "ground_threshold_m",
1691 "zero_poi_up_m",
1692 "zero_poi_right_m",
1693 ],
1694 )?;
1695 if let Some(reference) = object.get("drops_reference") {
1698 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1699 }
1700 Ok(())
1701}
1702
1703fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1704 let path = "$.atmosphere";
1705 let object = require_object(value, path)?;
1706 validate_members(
1707 object,
1708 path,
1709 &[
1710 "altitude_m",
1711 "temperature_k",
1712 "pressure_pa",
1713 "pressure_reference",
1714 "relative_humidity",
1715 "latitude_rad",
1716 ],
1717 &[],
1718 )?;
1719 validate_optional_numbers(
1720 object,
1721 path,
1722 &[
1723 "altitude_m",
1724 "temperature_k",
1725 "pressure_pa",
1726 "relative_humidity",
1727 "latitude_rad",
1728 ],
1729 )?;
1730 if let Some(reference) = object.get("pressure_reference") {
1736 validate_string_enum(
1737 reference,
1738 "$.atmosphere.pressure_reference",
1739 &["absolute", "qnh"],
1740 )?;
1741 }
1742 Ok(())
1743}
1744
1745fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1746 let path = "$.wind";
1747 let object = require_object(value, path)?;
1748 validate_members(
1749 object,
1750 path,
1751 &[
1752 "speed_mps",
1753 "direction_from_rad",
1754 "vertical_speed_mps",
1755 "segments",
1756 "wind_reference",
1757 ],
1758 &[],
1759 )?;
1760 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1761 validate_optional_number(object, path, field)?;
1762 }
1763 if let Some(reference) = object.get("wind_reference") {
1765 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1766 }
1767
1768 if let Some(segments) = object.get("segments") {
1769 let Some(segments) = segments.as_array() else {
1770 return Err(protocol_error(
1771 SolveErrorCodeV1::InvalidValue,
1772 "segments must be an array",
1773 "$.wind.segments",
1774 ));
1775 };
1776 for (index, segment) in segments.iter().enumerate() {
1777 let segment_path = format!("$.wind.segments[{index}]");
1778 let segment = require_object(segment, &segment_path)?;
1779 validate_members(
1780 segment,
1781 &segment_path,
1782 &[
1783 "until_distance_m",
1784 "speed_mps",
1785 "direction_from_rad",
1786 "vertical_speed_mps",
1787 ],
1788 &["until_distance_m", "speed_mps", "direction_from_rad"],
1789 )?;
1790 validate_required_numbers(
1791 segment,
1792 &segment_path,
1793 &["until_distance_m", "speed_mps", "direction_from_rad"],
1794 )?;
1795 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1796 }
1797 }
1798 Ok(())
1799}
1800
1801fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1802 let path = "$.solver";
1803 let object = require_object(value, path)?;
1804 validate_members(object, path, &["method", "time_step_s"], &[])?;
1805 validate_optional_number(object, path, "time_step_s")?;
1806 if let Some(method) = object.get("method") {
1807 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1808 }
1809 Ok(())
1810}
1811
1812pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1821 "magnus",
1822 "coriolis",
1823 "enhanced_spin_drift",
1824 "wind_shear_model",
1825 "aerodynamic_jump",
1826];
1827
1828fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1829 let path = "$.effects";
1830 let object = require_object(value, path)?;
1831 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1832 validate_optional_booleans(
1833 object,
1834 path,
1835 &[
1836 "magnus",
1837 "coriolis",
1838 "enhanced_spin_drift",
1839 "aerodynamic_jump",
1840 ],
1841 )?;
1842
1843 if let Some(model) = object.get("wind_shear_model") {
1847 validate_string_enum(
1848 model,
1849 "$.effects.wind_shear_model",
1850 &[
1851 "none",
1852 "logarithmic",
1853 "power_law",
1854 "ekman_spiral",
1855 "ekman",
1857 ],
1858 )?;
1859 }
1860
1861 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1862 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1863 {
1864 return Err(protocol_error(
1865 SolveErrorCodeV1::ConflictingFields,
1866 "magnus and enhanced_spin_drift cannot both be enabled",
1867 "$.effects",
1868 ));
1869 }
1870
1871 Ok(())
1872}
1873
1874fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1875 let path = "$.sampling";
1876 let object = require_object(value, path)?;
1877 validate_members(object, path, &["interval_m"], &[])?;
1878 validate_optional_number(object, path, "interval_m")
1879}
1880
1881fn require_object<'a>(
1882 value: &'a Value,
1883 path: &str,
1884) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1885 value
1886 .as_object()
1887 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1888}
1889
1890fn required_value<'a>(
1891 object: &'a Map<String, Value>,
1892 field: &str,
1893 parent_path: &str,
1894) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1895 object.get(field).ok_or_else(|| {
1896 protocol_error(
1897 SolveErrorCodeV1::MissingField,
1898 format!("missing required field `{field}`"),
1899 child_path(parent_path, field),
1900 )
1901 })
1902}
1903
1904fn validate_members(
1905 object: &Map<String, Value>,
1906 path: &str,
1907 allowed: &[&str],
1908 required: &[&str],
1909) -> Result<(), SolveErrorEnvelopeV1> {
1910 if let Some(field) = object
1911 .keys()
1912 .find(|field| !allowed.contains(&field.as_str()))
1913 {
1914 return Err(protocol_error(
1915 SolveErrorCodeV1::UnknownField,
1916 format!("unknown field `{field}`"),
1917 child_path(path, field),
1918 ));
1919 }
1920
1921 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1922 return Err(protocol_error(
1923 SolveErrorCodeV1::MissingField,
1924 format!("missing required field `{field}`"),
1925 child_path(path, field),
1926 ));
1927 }
1928 Ok(())
1929}
1930
1931fn validate_string_enum(
1932 value: &Value,
1933 path: &str,
1934 allowed: &[&str],
1935) -> Result<(), SolveErrorEnvelopeV1> {
1936 let Some(value) = value.as_str() else {
1937 return Err(protocol_error(
1938 SolveErrorCodeV1::InvalidValue,
1939 "expected a string enum value",
1940 path,
1941 ));
1942 };
1943 if !allowed.contains(&value) {
1944 return Err(protocol_error(
1945 SolveErrorCodeV1::InvalidValue,
1946 format!(
1947 "invalid value `{value}`; expected one of {}",
1948 allowed.join(", ")
1949 ),
1950 path,
1951 ));
1952 }
1953 Ok(())
1954}
1955
1956fn validate_required_numbers(
1957 object: &Map<String, Value>,
1958 parent_path: &str,
1959 fields: &[&str],
1960) -> Result<(), SolveErrorEnvelopeV1> {
1961 for field in fields {
1962 let value = required_value(object, field, parent_path)?;
1963 validate_number(value, &child_path(parent_path, field))?;
1964 }
1965 Ok(())
1966}
1967
1968fn validate_optional_numbers(
1969 object: &Map<String, Value>,
1970 parent_path: &str,
1971 fields: &[&str],
1972) -> Result<(), SolveErrorEnvelopeV1> {
1973 for field in fields {
1974 validate_optional_number(object, parent_path, field)?;
1975 }
1976 Ok(())
1977}
1978
1979fn validate_optional_number(
1980 object: &Map<String, Value>,
1981 parent_path: &str,
1982 field: &str,
1983) -> Result<(), SolveErrorEnvelopeV1> {
1984 if let Some(value) = object.get(field) {
1985 validate_number(value, &child_path(parent_path, field))?;
1986 }
1987 Ok(())
1988}
1989
1990fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1991 if value.is_number() {
1992 Ok(())
1993 } else {
1994 Err(protocol_error(
1995 SolveErrorCodeV1::InvalidValue,
1996 "expected a number",
1997 path,
1998 ))
1999 }
2000}
2001
2002fn validate_optional_booleans(
2003 object: &Map<String, Value>,
2004 parent_path: &str,
2005 fields: &[&str],
2006) -> Result<(), SolveErrorEnvelopeV1> {
2007 for field in fields {
2008 if let Some(value) = object.get(*field) {
2009 if !value.is_boolean() {
2010 return Err(protocol_error(
2011 SolveErrorCodeV1::InvalidValue,
2012 "expected a boolean",
2013 child_path(parent_path, field),
2014 ));
2015 }
2016 }
2017 }
2018 Ok(())
2019}
2020
2021fn child_path(parent: &str, field: &str) -> String {
2022 format!("{parent}.{field}")
2023}
2024
2025#[cfg(test)]
2027mod request_range_tests {
2028 use super::*;
2029
2030 fn valid_request_json() -> String {
2033 r#"{"schema_version":1,
2034 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
2035 "drag_model":"G7","ballistic_coefficient":0.243},
2036 "rifle":{"muzzle_velocity_mps":823.0},
2037 "shot":{"max_range_m":1000.0},
2038 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
2039 .to_string()
2040 }
2041
2042 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
2043 decode_solve_request_v1(json).expect_err("request should have been rejected")
2044 }
2045
2046 #[test]
2047 fn an_ordinary_request_still_decodes() {
2048 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
2049 }
2050
2051 #[test]
2057 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
2058 let reproducer = r#"{"schema_version":1,
2059 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
2060 "drag_model":"G7","ballistic_coefficient":1.2e2},
2061 "rifle":{"muzzle_velocity_mps":823.0},
2062 "shot":{"max_range_m":100.0},
2063 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
2064
2065 let envelope = decode_err(reproducer);
2066 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2067 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
2068 }
2069
2070 #[test]
2073 fn the_rejection_envelope_round_trips() {
2074 let envelope = decode_err(
2075 r#"{"schema_version":1,
2076 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
2077 "drag_model":"G7","ballistic_coefficient":0.243},
2078 "rifle":{"muzzle_velocity_mps":823.0},
2079 "shot":{"max_range_m":100.0},
2080 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2081 );
2082 let encoded = serde_json::to_string(&envelope).expect("serialize");
2083 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2084 assert_eq!(decoded, envelope);
2085 }
2086
2087 #[test]
2088 fn each_bounded_field_reports_its_own_path() {
2089 for (field, bad_value, path) in [
2090 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2091 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2092 (
2093 "ballistic_coefficient",
2094 "1.0e6",
2095 "$.projectile.ballistic_coefficient",
2096 ),
2097 ("length_m", "1.0e-9", "$.projectile.length_m"),
2098 ] {
2099 let json = valid_request_json().replace(
2100 &format!("\"{field}\":{}", default_for(field)),
2101 &format!("\"{field}\":{bad_value}"),
2102 );
2103 let envelope = decode_err(&json);
2104 assert_eq!(
2105 envelope.error.path(),
2106 Some(path),
2107 "wrong path for {field}"
2108 );
2109 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2110 }
2111 }
2112
2113 fn default_for(field: &str) -> &'static str {
2114 match field {
2115 "mass_kg" => "0.01134",
2116 "diameter_m" => "0.00782",
2117 "ballistic_coefficient" => "0.243",
2118 "length_m" => "0.031",
2119 other => panic!("no default recorded for {other}"),
2120 }
2121 }
2122
2123 #[test]
2127 fn muzzle_velocity_is_deliberately_left_unbounded() {
2128 let json = valid_request_json().replace("823.0", "1.0e308");
2129 decode_solve_request_v1(&json)
2130 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2131 }
2132}