1use serde::{
8 de::{self, Visitor},
9 ser::SerializeStruct,
10 Deserialize, Deserializer, Serialize, Serializer,
11};
12use serde_json::{Map, Value};
13use std::{fmt, num::NonZeroUsize};
14
15pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18pub const DRAG_MODEL_WIRE_NAMES_V1: [&str; 9] =
23 ["G1", "G2", "G5", "G6", "G7", "G8", "GI", "GS", "RA4"];
24
25pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
30
31fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
37where
38 D: Deserializer<'de>,
39 T: Deserialize<'de>,
40{
41 T::deserialize(deserializer).map(Some)
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
49pub struct SchemaVersionV1;
50
51impl SchemaVersionV1 {
52 pub const fn get(self) -> u32 {
54 SOLVE_JSON_SCHEMA_VERSION_V1
55 }
56}
57
58impl Serialize for SchemaVersionV1 {
59 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
60 where
61 S: Serializer,
62 {
63 serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
64 }
65}
66
67impl<'de> Deserialize<'de> for SchemaVersionV1 {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: Deserializer<'de>,
71 {
72 struct SchemaVersionVisitor;
73
74 impl<'de> Visitor<'de> for SchemaVersionVisitor {
75 type Value = SchemaVersionV1;
76
77 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 formatter.write_str("the integer 1")
79 }
80
81 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
82 where
83 E: de::Error,
84 {
85 if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
86 Ok(SchemaVersionV1)
87 } else {
88 Err(E::custom(format!(
89 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
90 )))
91 }
92 }
93
94 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
95 where
96 E: de::Error,
97 {
98 if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
99 Ok(SchemaVersionV1)
100 } else {
101 Err(E::custom(format!(
102 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
103 )))
104 }
105 }
106 }
107
108 deserializer.deserialize_any(SchemaVersionVisitor)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct SolveRequestV1 {
119 pub schema_version: SchemaVersionV1,
121 pub projectile: ProjectileV1,
122 pub rifle: RifleV1,
123 pub shot: ShotV1,
124 pub atmosphere: AtmosphereV1,
125 pub wind: WindV1,
126 pub solver: SolverV1,
127 pub effects: EffectsV1,
128 pub sampling: SamplingV1,
129 #[serde(
136 default,
137 skip_serializing_if = "Option::is_none",
138 deserialize_with = "deserialize_present"
139 )]
140 pub reticle: Option<ReticleRequestV1>,
141 #[serde(
147 default,
148 skip_serializing_if = "Option::is_none",
149 deserialize_with = "deserialize_present"
150 )]
151 pub corrections: Option<CorrectionsV1>,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct CorrectionsV1 {
169 #[serde(
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present"
174 )]
175 pub bc5d_table_path: Option<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct ReticleRequestV1 {
188 pub range_m: f64,
190 pub magnification: f64,
193 pub description: crate::reticle::ReticleDescription,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ReticleHoldV1 {
203 pub range_m: f64,
205 pub magnification: f64,
207 pub down_mil: f64,
208 pub right_mil: f64,
209 pub mark_scale: f64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub nearest_mark_index: Option<usize>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub nearest_mark_label: Option<String>,
219 pub nearest_mark_distance_mil: f64,
220 pub off_reticle: bool,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct ProjectileV1 {
229 pub mass_kg: f64,
230 pub diameter_m: f64,
231 #[serde(
232 default,
233 skip_serializing_if = "Option::is_none",
234 deserialize_with = "deserialize_present"
235 )]
236 pub length_m: Option<f64>,
237 pub drag_model: DragModelV1,
238 pub ballistic_coefficient: f64,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243pub enum DragModelV1 {
244 #[serde(rename = "G1")]
245 G1,
246 #[serde(rename = "G6")]
247 G6,
248 #[serde(rename = "G7")]
249 G7,
250 #[serde(rename = "G8")]
251 G8,
252 #[serde(rename = "G2")]
253 G2,
254 #[serde(rename = "G5")]
255 G5,
256 #[serde(rename = "GI")]
257 GI,
258 #[serde(rename = "GS")]
259 GS,
260 #[serde(rename = "RA4")]
261 RA4,
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(deny_unknown_fields)]
267pub struct RifleV1 {
268 pub muzzle_velocity_mps: f64,
269 #[serde(
270 default,
271 skip_serializing_if = "Option::is_none",
272 deserialize_with = "deserialize_present"
273 )]
274 pub sight_height_m: Option<f64>,
275 #[serde(
276 default,
277 skip_serializing_if = "Option::is_none",
278 deserialize_with = "deserialize_present"
279 )]
280 pub muzzle_height_m: Option<f64>,
281 #[serde(
282 default,
283 skip_serializing_if = "Option::is_none",
284 deserialize_with = "deserialize_present"
285 )]
286 pub twist_rate_m_per_turn: Option<f64>,
287 #[serde(
288 default,
289 skip_serializing_if = "Option::is_none",
290 deserialize_with = "deserialize_present"
291 )]
292 pub twist_direction: Option<TwistDirectionV1>,
293 #[serde(
301 default,
302 skip_serializing_if = "Option::is_none",
303 deserialize_with = "deserialize_present"
304 )]
305 pub sight_offset_lateral_m: Option<f64>,
306}
307
308#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum TwistDirectionV1 {
312 Left,
313 #[default]
314 Right,
315}
316
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct ShotV1 {
321 pub max_range_m: f64,
322 #[serde(
323 default,
324 skip_serializing_if = "Option::is_none",
325 deserialize_with = "deserialize_present"
326 )]
327 pub zero_distance_m: Option<f64>,
328 #[serde(
329 default,
330 skip_serializing_if = "Option::is_none",
331 deserialize_with = "deserialize_present"
332 )]
333 pub muzzle_angle_rad: Option<f64>,
334 #[serde(
335 default,
336 skip_serializing_if = "Option::is_none",
337 deserialize_with = "deserialize_present"
338 )]
339 pub aim_azimuth_rad: Option<f64>,
340 #[serde(
341 default,
342 skip_serializing_if = "Option::is_none",
343 deserialize_with = "deserialize_present"
344 )]
345 pub shot_azimuth_rad: Option<f64>,
346 #[serde(
347 default,
348 skip_serializing_if = "Option::is_none",
349 deserialize_with = "deserialize_present"
350 )]
351 pub shooting_angle_rad: Option<f64>,
352 #[serde(
353 default,
354 skip_serializing_if = "Option::is_none",
355 deserialize_with = "deserialize_present"
356 )]
357 pub cant_angle_rad: Option<f64>,
358 #[serde(
359 default,
360 skip_serializing_if = "Option::is_none",
361 deserialize_with = "deserialize_present"
362 )]
363 pub target_height_m: Option<f64>,
364 #[serde(
365 default,
366 skip_serializing_if = "Option::is_none",
367 deserialize_with = "deserialize_present"
368 )]
369 pub ground_threshold_m: Option<f64>,
370 #[serde(
376 default,
377 skip_serializing_if = "Option::is_none",
378 deserialize_with = "deserialize_present"
379 )]
380 pub zero_poi_up_m: Option<f64>,
381 #[serde(
385 default,
386 skip_serializing_if = "Option::is_none",
387 deserialize_with = "deserialize_present"
388 )]
389 pub zero_poi_right_m: Option<f64>,
390 #[serde(
398 default,
399 skip_serializing_if = "Option::is_none",
400 deserialize_with = "deserialize_present"
401 )]
402 pub drops_reference: Option<DropsReferenceV1>,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum DropsReferenceV1 {
409 #[default]
411 Los,
412 Target,
415}
416
417#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct AtmosphereV1 {
421 #[serde(
422 default,
423 skip_serializing_if = "Option::is_none",
424 deserialize_with = "deserialize_present"
425 )]
426 pub altitude_m: Option<f64>,
427 #[serde(
430 default,
431 skip_serializing_if = "Option::is_none",
432 deserialize_with = "deserialize_present"
433 )]
434 pub temperature_k: Option<f64>,
435 #[serde(
441 default,
442 skip_serializing_if = "Option::is_none",
443 deserialize_with = "deserialize_present"
444 )]
445 pub pressure_pa: Option<f64>,
446 #[serde(
452 default,
453 skip_serializing_if = "Option::is_none",
454 deserialize_with = "deserialize_present"
455 )]
456 pub pressure_reference: Option<PressureReferenceV1>,
457 #[serde(
458 default,
459 skip_serializing_if = "Option::is_none",
460 deserialize_with = "deserialize_present"
461 )]
462 pub relative_humidity: Option<f64>,
463 #[serde(
464 default,
465 skip_serializing_if = "Option::is_none",
466 deserialize_with = "deserialize_present"
467 )]
468 pub latitude_rad: Option<f64>,
469}
470
471#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "snake_case")]
476pub enum PressureReferenceV1 {
477 #[default]
478 Absolute,
479 Qnh,
480}
481
482#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
487#[serde(deny_unknown_fields)]
488pub struct WindV1 {
489 #[serde(
490 default,
491 skip_serializing_if = "Option::is_none",
492 deserialize_with = "deserialize_present"
493 )]
494 pub speed_mps: Option<f64>,
495 #[serde(
496 default,
497 skip_serializing_if = "Option::is_none",
498 deserialize_with = "deserialize_present"
499 )]
500 pub direction_from_rad: Option<f64>,
501 #[serde(
502 default,
503 skip_serializing_if = "Option::is_none",
504 deserialize_with = "deserialize_present"
505 )]
506 pub vertical_speed_mps: Option<f64>,
507 #[serde(
508 default,
509 skip_serializing_if = "Option::is_none",
510 deserialize_with = "deserialize_present"
511 )]
512 pub segments: Option<Vec<WindSegmentV1>>,
513 #[serde(
526 default,
527 skip_serializing_if = "Option::is_none",
528 deserialize_with = "deserialize_present"
529 )]
530 pub wind_reference: Option<WindReferenceV1>,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536pub enum WindReferenceV1 {
537 #[default]
539 Shooter,
540 Compass,
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
546#[serde(deny_unknown_fields)]
547pub struct WindSegmentV1 {
548 pub until_distance_m: f64,
549 pub speed_mps: f64,
550 pub direction_from_rad: f64,
551 #[serde(
552 default,
553 skip_serializing_if = "Option::is_none",
554 deserialize_with = "deserialize_present"
555 )]
556 pub vertical_speed_mps: Option<f64>,
557}
558
559#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
561#[serde(deny_unknown_fields)]
562pub struct SolverV1 {
563 #[serde(
564 default,
565 skip_serializing_if = "Option::is_none",
566 deserialize_with = "deserialize_present"
567 )]
568 pub method: Option<SolverMethodV1>,
569 #[serde(
571 default,
572 skip_serializing_if = "Option::is_none",
573 deserialize_with = "deserialize_present"
574 )]
575 pub time_step_s: Option<f64>,
576}
577
578#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(rename_all = "snake_case")]
581pub enum SolverMethodV1 {
582 Euler,
583 Rk4,
584 #[default]
585 Rk45,
586}
587
588#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
590#[serde(deny_unknown_fields)]
591pub struct EffectsV1 {
592 #[serde(
593 default,
594 skip_serializing_if = "Option::is_none",
595 deserialize_with = "deserialize_present"
596 )]
597 pub magnus: Option<bool>,
598 #[serde(
599 default,
600 skip_serializing_if = "Option::is_none",
601 deserialize_with = "deserialize_present"
602 )]
603 pub coriolis: Option<bool>,
604 #[serde(
605 default,
606 skip_serializing_if = "Option::is_none",
607 deserialize_with = "deserialize_present"
608 )]
609 pub enhanced_spin_drift: Option<bool>,
610 #[serde(
620 default,
621 skip_serializing_if = "Option::is_none",
622 deserialize_with = "deserialize_present"
623 )]
624 pub wind_shear_model: Option<WindShearModelV1>,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
635#[serde(rename_all = "snake_case")]
636pub enum WindShearModelV1 {
637 #[default]
640 None,
641 Logarithmic,
643 PowerLaw,
645 #[serde(alias = "ekman")]
650 EkmanSpiral,
651}
652
653impl WindShearModelV1 {
654 pub fn as_engine_str(self) -> &'static str {
659 match self {
660 WindShearModelV1::None => "none",
661 WindShearModelV1::Logarithmic => "logarithmic",
662 WindShearModelV1::PowerLaw => "power_law",
663 WindShearModelV1::EkmanSpiral => "ekman_spiral",
664 }
665 }
666
667 pub fn is_enabled(self) -> bool {
674 !matches!(self, WindShearModelV1::None)
675 }
676}
677
678#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
680#[serde(deny_unknown_fields)]
681pub struct SamplingV1 {
682 #[serde(
683 default,
684 skip_serializing_if = "Option::is_none",
685 deserialize_with = "deserialize_present"
686 )]
687 pub interval_m: Option<f64>,
688}
689
690#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct ResolvedSolveRequestV1 {
698 pub schema_version: SchemaVersionV1,
699 pub projectile: ResolvedProjectileV1,
700 pub rifle: ResolvedRifleV1,
701 pub shot: ResolvedShotV1,
702 pub atmosphere: ResolvedAtmosphereV1,
703 pub wind: ResolvedWindV1,
704 pub solver: ResolvedSolverV1,
705 pub effects: ResolvedEffectsV1,
706 pub sampling: ResolvedSamplingV1,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub reticle: Option<ReticleRequestV1>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub corrections: Option<CorrectionsV1>,
719}
720
721#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct ResolvedProjectileV1 {
725 pub mass_kg: f64,
726 pub diameter_m: f64,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub length_m: Option<f64>,
729 pub drag_model: DragModelV1,
730 pub ballistic_coefficient: f64,
731}
732
733#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[serde(deny_unknown_fields)]
736pub struct ResolvedRifleV1 {
737 pub muzzle_velocity_mps: f64,
738 pub sight_height_m: f64,
739 pub muzzle_height_m: f64,
740 pub twist_rate_m_per_turn: f64,
741 pub twist_direction: TwistDirectionV1,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub sight_offset_lateral_m: Option<f64>,
746}
747
748#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ResolvedShotV1 {
762 pub max_range_m: f64,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub zero_distance_m: Option<f64>,
765 pub muzzle_angle_rad: f64,
766 pub aim_azimuth_rad: f64,
767 pub shot_azimuth_rad: f64,
768 pub shooting_angle_rad: f64,
769 pub cant_angle_rad: f64,
770 pub target_height_m: f64,
771 pub ground_threshold_m: f64,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
775 pub zero_poi_up_m: Option<f64>,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub zero_poi_right_m: Option<f64>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub drops_reference: Option<DropsReferenceV1>,
784}
785
786#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
788#[serde(deny_unknown_fields)]
789pub struct ResolvedAtmosphereV1 {
790 pub altitude_m: f64,
791 pub temperature_k: f64,
792 pub pressure_pa: f64,
793 pub relative_humidity: f64,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub latitude_rad: Option<f64>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub pressure_reference: Option<PressureReferenceV1>,
800}
801
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
807#[serde(untagged)]
808pub enum ResolvedWindV1 {
809 Constant(ResolvedConstantWindV1),
810 Segmented(ResolvedSegmentedWindV1),
811}
812
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
815#[serde(deny_unknown_fields)]
816pub struct ResolvedConstantWindV1 {
817 pub speed_mps: f64,
818 pub direction_from_rad: f64,
819 pub vertical_speed_mps: f64,
820 #[serde(default, skip_serializing_if = "Option::is_none")]
824 pub wind_reference: Option<WindReferenceV1>,
825}
826
827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
829#[serde(deny_unknown_fields)]
830pub struct ResolvedSegmentedWindV1 {
831 pub segments: Vec<ResolvedWindSegmentV1>,
832 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub wind_reference: Option<WindReferenceV1>,
837}
838
839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
841#[serde(deny_unknown_fields)]
842pub struct ResolvedWindSegmentV1 {
843 pub until_distance_m: f64,
844 pub speed_mps: f64,
845 pub direction_from_rad: f64,
846 pub vertical_speed_mps: f64,
847}
848
849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851#[serde(deny_unknown_fields)]
852pub struct ResolvedSolverV1 {
853 pub method: SolverMethodV1,
854 pub time_step_s: f64,
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
859#[serde(deny_unknown_fields)]
860pub struct ResolvedEffectsV1 {
861 pub magnus: bool,
862 pub coriolis: bool,
863 pub enhanced_spin_drift: bool,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub wind_shear_model: Option<WindShearModelV1>,
869}
870
871#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
873#[serde(deny_unknown_fields)]
874pub struct ResolvedSamplingV1 {
875 pub interval_m: f64,
876}
877
878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
880#[serde(deny_unknown_fields)]
881pub struct SolveSuccessV1 {
882 pub schema_version: SchemaVersionV1,
883 pub engine_version: String,
884 pub status: SuccessStatusV1,
885 pub resolved_request: ResolvedSolveRequestV1,
886 #[serde(default)]
887 pub assumptions: Vec<SolveNoticeV1>,
888 #[serde(default)]
889 pub warnings: Vec<SolveNoticeV1>,
890 pub summary: SolveSummaryV1,
891 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
892 pub samples: Vec<TrajectorySampleV1>,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
897 pub reticle_hold: Option<ReticleHoldV1>,
898}
899
900fn serialize_solve_samples_v1<S>(
901 samples: &[TrajectorySampleV1],
902 serializer: S,
903) -> Result<S::Ok, S::Error>
904where
905 S: Serializer,
906{
907 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
908 return Err(serde::ser::Error::custom(format_args!(
909 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
910 samples.len()
911 )));
912 }
913 samples.serialize(serializer)
914}
915
916impl SolveSuccessV1 {
917 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
923 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
924 return Ok(());
925 }
926
927 Err(SolveErrorEnvelopeV1::new(
928 SolveErrorV1::new(
929 SolveErrorCodeV1::ResourceLimit,
930 format!(
931 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
932 self.samples.len()
933 ),
934 )
935 .at_path("$.sampling.interval_m"),
936 ))
937 }
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
942#[serde(rename_all = "snake_case")]
943pub enum SuccessStatusV1 {
944 Ok,
945}
946
947#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct SolveNoticeV1 {
951 pub code: String,
952 pub message: String,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub path: Option<String>,
955}
956
957#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
959#[serde(deny_unknown_fields)]
960pub struct SolveSummaryV1 {
961 pub actual_range_m: f64,
962 pub maximum_height_m: f64,
965 pub time_of_flight_s: f64,
966 pub terminal_speed_mps: f64,
967 pub terminal_energy_j: f64,
968 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub stability_factor: Option<f64>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub spin_drift_m: Option<f64>,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub equivalent_horizontal_range_m: Option<f64>,
986 pub termination: TerminationReasonV1,
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
991#[serde(rename_all = "snake_case")]
992pub enum TerminationReasonV1 {
993 MaxRange,
994 GroundThreshold,
995 TimeLimit,
996 VelocityFloor,
997}
998
999#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1001#[serde(deny_unknown_fields)]
1002pub struct TrajectorySampleV1 {
1003 pub distance_m: f64,
1004 pub time_s: f64,
1005 pub speed_mps: f64,
1006 pub energy_j: f64,
1007 pub drop_m: f64,
1009 pub windage_m: f64,
1011 pub mach: f64,
1012 #[serde(default)]
1013 pub flags: Vec<SampleFlagV1>,
1014}
1015
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1018#[serde(rename_all = "snake_case")]
1019pub enum SampleFlagV1 {
1020 Transonic,
1021 Subsonic,
1022 Terminal,
1023 GroundThreshold,
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1028#[serde(deny_unknown_fields)]
1029pub struct SolveErrorEnvelopeV1 {
1030 pub schema_version: SchemaVersionV1,
1031 pub status: ErrorStatusV1,
1032 pub error: SolveErrorV1,
1033}
1034
1035impl SolveErrorEnvelopeV1 {
1036 pub fn new(error: SolveErrorV1) -> Self {
1038 Self {
1039 schema_version: SchemaVersionV1,
1040 status: ErrorStatusV1::Error,
1041 error,
1042 }
1043 }
1044}
1045
1046#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1048#[serde(rename_all = "snake_case")]
1049pub enum ErrorStatusV1 {
1050 Error,
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1059pub struct SolveErrorV1 {
1060 pub code: SolveErrorCodeV1,
1061 pub message: String,
1062 location: SolveErrorLocationV1,
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Eq)]
1066enum SolveErrorLocationV1 {
1067 None,
1068 Path(String),
1069 Source {
1070 line: NonZeroUsize,
1071 column: NonZeroUsize,
1072 },
1073}
1074
1075#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1077pub enum SolveErrorLocationErrorV1 {
1078 ZeroLine,
1079 ZeroColumn,
1080}
1081
1082impl fmt::Display for SolveErrorLocationErrorV1 {
1083 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1084 match self {
1085 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1086 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1087 }
1088 }
1089}
1090
1091impl std::error::Error for SolveErrorLocationErrorV1 {}
1092
1093impl SolveErrorV1 {
1094 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1096 Self {
1097 code,
1098 message: message.into(),
1099 location: SolveErrorLocationV1::None,
1100 }
1101 }
1102
1103 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1105 self.location = SolveErrorLocationV1::Path(path.into());
1106 self
1107 }
1108
1109 pub fn at_location(
1111 mut self,
1112 line: usize,
1113 column: usize,
1114 ) -> Result<Self, SolveErrorLocationErrorV1> {
1115 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1116 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1117 self.location = SolveErrorLocationV1::Source { line, column };
1118 Ok(self)
1119 }
1120
1121 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1126 self.location = SolveErrorLocationV1::Source {
1127 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1128 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1129 };
1130 self
1131 }
1132
1133 pub fn path(&self) -> Option<&str> {
1135 match &self.location {
1136 SolveErrorLocationV1::Path(path) => Some(path),
1137 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1138 }
1139 }
1140
1141 pub fn line(&self) -> Option<usize> {
1143 match &self.location {
1144 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1145 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1146 }
1147 }
1148
1149 pub fn column(&self) -> Option<usize> {
1151 match &self.location {
1152 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1153 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1154 }
1155 }
1156}
1157
1158impl Serialize for SolveErrorV1 {
1159 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1160 where
1161 S: Serializer,
1162 {
1163 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1164 state.serialize_field("code", &self.code)?;
1165 state.serialize_field("message", &self.message)?;
1166 state.serialize_field("path", &self.path())?;
1167 state.serialize_field("line", &self.line())?;
1168 state.serialize_field("column", &self.column())?;
1169 state.end()
1170 }
1171}
1172
1173impl<'de> Deserialize<'de> for SolveErrorV1 {
1174 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1175 where
1176 D: Deserializer<'de>,
1177 {
1178 #[derive(Deserialize)]
1179 #[serde(deny_unknown_fields)]
1180 struct SolveErrorWireV1 {
1181 code: SolveErrorCodeV1,
1182 message: String,
1183 path: Option<String>,
1184 line: Option<usize>,
1185 column: Option<usize>,
1186 }
1187
1188 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1189 let location = match (wire.path, wire.line, wire.column) {
1190 (None, None, None) => SolveErrorLocationV1::None,
1191 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1192 (None, Some(line), Some(column)) => {
1193 let line = NonZeroUsize::new(line)
1194 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1195 let column = NonZeroUsize::new(column)
1196 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1197 SolveErrorLocationV1::Source { line, column }
1198 }
1199 _ => {
1200 return Err(de::Error::custom(
1201 "error location must contain either path or both line and column",
1202 ));
1203 }
1204 };
1205
1206 Ok(Self {
1207 code: wire.code,
1208 message: wire.message,
1209 location,
1210 })
1211 }
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1216#[serde(rename_all = "snake_case")]
1217pub enum SolveErrorCodeV1 {
1218 InvalidJson,
1219 UnsupportedSchemaVersion,
1220 UnknownField,
1221 MissingField,
1222 InvalidValue,
1223 ConflictingFields,
1224 ResourceLimit,
1225 SolveFailed,
1226 IoError,
1227 InternalError,
1228}
1229
1230pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1237 let value: Value = serde_json::from_str(input).map_err(|error| {
1238 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1239 .at_parser_location(error.line(), error.column());
1240 envelope(error)
1241 })?;
1242
1243 validate_request_shape(&value)?;
1244
1245 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1246 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1247 })?;
1248
1249 validate_request_ranges(&request)?;
1250
1251 Ok(request)
1252}
1253
1254mod limits {
1267 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1271 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1273 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1275 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1277
1278 }
1285
1286fn require_range(
1287 value: f64,
1288 (min, max): (f64, f64),
1289 path: &str,
1290) -> Result<(), SolveErrorEnvelopeV1> {
1291 if !value.is_finite() {
1292 return Err(protocol_error(
1293 SolveErrorCodeV1::InvalidValue,
1294 format!("{path} must be a finite number"),
1295 path,
1296 ));
1297 }
1298 if value < min || value > max {
1299 return Err(protocol_error(
1300 SolveErrorCodeV1::InvalidValue,
1301 format!("{path} must be between {min} and {max}, got {value}"),
1302 path,
1303 ));
1304 }
1305 Ok(())
1306}
1307
1308fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1313 require_range(
1314 request.projectile.mass_kg,
1315 limits::MASS_KG,
1316 "$.projectile.mass_kg",
1317 )?;
1318 require_range(
1319 request.projectile.diameter_m,
1320 limits::DIAMETER_M,
1321 "$.projectile.diameter_m",
1322 )?;
1323 require_range(
1324 request.projectile.ballistic_coefficient,
1325 limits::BALLISTIC_COEFFICIENT,
1326 "$.projectile.ballistic_coefficient",
1327 )?;
1328 if let Some(length_m) = request.projectile.length_m {
1329 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1330 }
1331 Ok(())
1332}
1333
1334fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1335 SolveErrorEnvelopeV1::new(error)
1336}
1337
1338fn protocol_error(
1339 code: SolveErrorCodeV1,
1340 message: impl Into<String>,
1341 path: impl Into<String>,
1342) -> SolveErrorEnvelopeV1 {
1343 envelope(SolveErrorV1::new(code, message).at_path(path))
1344}
1345
1346fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1347 let root = require_object(value, "$")?;
1348
1349 validate_schema_version(root)?;
1353 validate_members(
1354 root,
1355 "$",
1356 &[
1357 "schema_version",
1358 "projectile",
1359 "rifle",
1360 "shot",
1361 "atmosphere",
1362 "wind",
1363 "solver",
1364 "effects",
1365 "sampling",
1366 "reticle",
1368 "corrections",
1370 ],
1371 &[
1372 "schema_version",
1373 "projectile",
1374 "rifle",
1375 "shot",
1376 "atmosphere",
1377 "wind",
1378 "solver",
1379 "effects",
1380 "sampling",
1381 ],
1382 )?;
1383
1384 validate_projectile(required_value(root, "projectile", "$")?)?;
1385 validate_rifle(required_value(root, "rifle", "$")?)?;
1386 validate_shot(required_value(root, "shot", "$")?)?;
1387 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1388 validate_wind(required_value(root, "wind", "$")?)?;
1389 validate_solver(required_value(root, "solver", "$")?)?;
1390 validate_effects(required_value(root, "effects", "$")?)?;
1391 validate_sampling(required_value(root, "sampling", "$")?)?;
1392 if let Some(reticle) = root.get("reticle") {
1393 validate_reticle(reticle)?;
1394 }
1395 if let Some(corrections) = root.get("corrections") {
1396 validate_corrections(corrections)?;
1397 }
1398 Ok(())
1399}
1400
1401fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1406 let path = "$.corrections";
1407 let object = require_object(value, path)?;
1408 validate_members(object, path, &["bc5d_table_path"], &[])?;
1409 if let Some(table_path) = object.get("bc5d_table_path") {
1410 if !table_path.is_string() {
1411 return Err(protocol_error(
1412 SolveErrorCodeV1::InvalidValue,
1413 "expected a string",
1414 "$.corrections.bc5d_table_path",
1415 ));
1416 }
1417 }
1418 Ok(())
1419}
1420
1421fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1428 let path = "$.reticle";
1429 let object = require_object(value, path)?;
1430 validate_members(
1431 object,
1432 path,
1433 &["range_m", "magnification", "description"],
1434 &["range_m", "magnification", "description"],
1435 )?;
1436 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1437 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1438 Ok(())
1439}
1440
1441fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1442 let value = required_value(root, "schema_version", "$")?;
1443 let version = if let Some(version) = value.as_i64() {
1444 i128::from(version)
1445 } else if let Some(version) = value.as_u64() {
1446 i128::from(version)
1447 } else {
1448 return Err(protocol_error(
1449 SolveErrorCodeV1::InvalidValue,
1450 "schema_version must be the integer 1",
1451 "$.schema_version",
1452 ));
1453 };
1454 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1455 return Err(protocol_error(
1456 SolveErrorCodeV1::UnsupportedSchemaVersion,
1457 format!(
1458 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1459 ),
1460 "$.schema_version",
1461 ));
1462 }
1463 Ok(())
1464}
1465
1466fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1467 let path = "$.projectile";
1468 let object = require_object(value, path)?;
1469 validate_members(
1470 object,
1471 path,
1472 &[
1473 "mass_kg",
1474 "diameter_m",
1475 "length_m",
1476 "drag_model",
1477 "ballistic_coefficient",
1478 ],
1479 &[
1480 "mass_kg",
1481 "diameter_m",
1482 "drag_model",
1483 "ballistic_coefficient",
1484 ],
1485 )?;
1486 validate_required_numbers(
1487 object,
1488 path,
1489 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1490 )?;
1491 validate_optional_number(object, path, "length_m")?;
1492 validate_string_enum(
1493 required_value(object, "drag_model", path)?,
1494 "$.projectile.drag_model",
1495 &DRAG_MODEL_WIRE_NAMES_V1,
1496 )
1497}
1498
1499fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1500 let path = "$.rifle";
1501 let object = require_object(value, path)?;
1502 validate_members(
1503 object,
1504 path,
1505 &[
1506 "muzzle_velocity_mps",
1507 "sight_height_m",
1508 "muzzle_height_m",
1509 "twist_rate_m_per_turn",
1510 "twist_direction",
1511 "sight_offset_lateral_m",
1512 ],
1513 &["muzzle_velocity_mps"],
1514 )?;
1515 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1516 validate_optional_numbers(
1517 object,
1518 path,
1519 &[
1520 "sight_height_m",
1521 "muzzle_height_m",
1522 "twist_rate_m_per_turn",
1523 "sight_offset_lateral_m",
1524 ],
1525 )?;
1526 if let Some(direction) = object.get("twist_direction") {
1527 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1528 }
1529 Ok(())
1530}
1531
1532fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1533 let path = "$.shot";
1534 let object = require_object(value, path)?;
1535 validate_members(
1536 object,
1537 path,
1538 &[
1539 "max_range_m",
1540 "zero_distance_m",
1541 "muzzle_angle_rad",
1542 "aim_azimuth_rad",
1543 "shot_azimuth_rad",
1544 "shooting_angle_rad",
1545 "cant_angle_rad",
1546 "target_height_m",
1547 "ground_threshold_m",
1548 "zero_poi_up_m",
1549 "zero_poi_right_m",
1550 "drops_reference",
1551 ],
1552 &["max_range_m"],
1553 )?;
1554 validate_required_numbers(object, path, &["max_range_m"])?;
1555 validate_optional_number(object, path, "zero_distance_m")?;
1556 validate_optional_number(object, path, "muzzle_angle_rad")?;
1557 validate_optional_numbers(
1558 object,
1559 path,
1560 &[
1561 "aim_azimuth_rad",
1562 "shot_azimuth_rad",
1563 "shooting_angle_rad",
1564 "cant_angle_rad",
1565 "target_height_m",
1566 "ground_threshold_m",
1567 "zero_poi_up_m",
1568 "zero_poi_right_m",
1569 ],
1570 )?;
1571 if let Some(reference) = object.get("drops_reference") {
1574 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1575 }
1576 Ok(())
1577}
1578
1579fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1580 let path = "$.atmosphere";
1581 let object = require_object(value, path)?;
1582 validate_members(
1583 object,
1584 path,
1585 &[
1586 "altitude_m",
1587 "temperature_k",
1588 "pressure_pa",
1589 "pressure_reference",
1590 "relative_humidity",
1591 "latitude_rad",
1592 ],
1593 &[],
1594 )?;
1595 validate_optional_numbers(
1596 object,
1597 path,
1598 &[
1599 "altitude_m",
1600 "temperature_k",
1601 "pressure_pa",
1602 "relative_humidity",
1603 "latitude_rad",
1604 ],
1605 )?;
1606 if let Some(reference) = object.get("pressure_reference") {
1612 validate_string_enum(
1613 reference,
1614 "$.atmosphere.pressure_reference",
1615 &["absolute", "qnh"],
1616 )?;
1617 }
1618 Ok(())
1619}
1620
1621fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1622 let path = "$.wind";
1623 let object = require_object(value, path)?;
1624 validate_members(
1625 object,
1626 path,
1627 &[
1628 "speed_mps",
1629 "direction_from_rad",
1630 "vertical_speed_mps",
1631 "segments",
1632 "wind_reference",
1633 ],
1634 &[],
1635 )?;
1636 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1637 validate_optional_number(object, path, field)?;
1638 }
1639 if let Some(reference) = object.get("wind_reference") {
1641 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1642 }
1643
1644 if let Some(segments) = object.get("segments") {
1645 let Some(segments) = segments.as_array() else {
1646 return Err(protocol_error(
1647 SolveErrorCodeV1::InvalidValue,
1648 "segments must be an array",
1649 "$.wind.segments",
1650 ));
1651 };
1652 for (index, segment) in segments.iter().enumerate() {
1653 let segment_path = format!("$.wind.segments[{index}]");
1654 let segment = require_object(segment, &segment_path)?;
1655 validate_members(
1656 segment,
1657 &segment_path,
1658 &[
1659 "until_distance_m",
1660 "speed_mps",
1661 "direction_from_rad",
1662 "vertical_speed_mps",
1663 ],
1664 &["until_distance_m", "speed_mps", "direction_from_rad"],
1665 )?;
1666 validate_required_numbers(
1667 segment,
1668 &segment_path,
1669 &["until_distance_m", "speed_mps", "direction_from_rad"],
1670 )?;
1671 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1672 }
1673 }
1674 Ok(())
1675}
1676
1677fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1678 let path = "$.solver";
1679 let object = require_object(value, path)?;
1680 validate_members(object, path, &["method", "time_step_s"], &[])?;
1681 validate_optional_number(object, path, "time_step_s")?;
1682 if let Some(method) = object.get("method") {
1683 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1684 }
1685 Ok(())
1686}
1687
1688fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1689 let path = "$.effects";
1690 let object = require_object(value, path)?;
1691 validate_members(
1692 object,
1693 path,
1694 &["magnus", "coriolis", "enhanced_spin_drift", "wind_shear_model"],
1695 &[],
1696 )?;
1697 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1698
1699 if let Some(model) = object.get("wind_shear_model") {
1703 validate_string_enum(
1704 model,
1705 "$.effects.wind_shear_model",
1706 &[
1707 "none",
1708 "logarithmic",
1709 "power_law",
1710 "ekman_spiral",
1711 "ekman",
1713 ],
1714 )?;
1715 }
1716
1717 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1718 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1719 {
1720 return Err(protocol_error(
1721 SolveErrorCodeV1::ConflictingFields,
1722 "magnus and enhanced_spin_drift cannot both be enabled",
1723 "$.effects",
1724 ));
1725 }
1726
1727 Ok(())
1728}
1729
1730fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1731 let path = "$.sampling";
1732 let object = require_object(value, path)?;
1733 validate_members(object, path, &["interval_m"], &[])?;
1734 validate_optional_number(object, path, "interval_m")
1735}
1736
1737fn require_object<'a>(
1738 value: &'a Value,
1739 path: &str,
1740) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1741 value
1742 .as_object()
1743 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1744}
1745
1746fn required_value<'a>(
1747 object: &'a Map<String, Value>,
1748 field: &str,
1749 parent_path: &str,
1750) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1751 object.get(field).ok_or_else(|| {
1752 protocol_error(
1753 SolveErrorCodeV1::MissingField,
1754 format!("missing required field `{field}`"),
1755 child_path(parent_path, field),
1756 )
1757 })
1758}
1759
1760fn validate_members(
1761 object: &Map<String, Value>,
1762 path: &str,
1763 allowed: &[&str],
1764 required: &[&str],
1765) -> Result<(), SolveErrorEnvelopeV1> {
1766 if let Some(field) = object
1767 .keys()
1768 .find(|field| !allowed.contains(&field.as_str()))
1769 {
1770 return Err(protocol_error(
1771 SolveErrorCodeV1::UnknownField,
1772 format!("unknown field `{field}`"),
1773 child_path(path, field),
1774 ));
1775 }
1776
1777 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1778 return Err(protocol_error(
1779 SolveErrorCodeV1::MissingField,
1780 format!("missing required field `{field}`"),
1781 child_path(path, field),
1782 ));
1783 }
1784 Ok(())
1785}
1786
1787fn validate_string_enum(
1788 value: &Value,
1789 path: &str,
1790 allowed: &[&str],
1791) -> Result<(), SolveErrorEnvelopeV1> {
1792 let Some(value) = value.as_str() else {
1793 return Err(protocol_error(
1794 SolveErrorCodeV1::InvalidValue,
1795 "expected a string enum value",
1796 path,
1797 ));
1798 };
1799 if !allowed.contains(&value) {
1800 return Err(protocol_error(
1801 SolveErrorCodeV1::InvalidValue,
1802 format!(
1803 "invalid value `{value}`; expected one of {}",
1804 allowed.join(", ")
1805 ),
1806 path,
1807 ));
1808 }
1809 Ok(())
1810}
1811
1812fn validate_required_numbers(
1813 object: &Map<String, Value>,
1814 parent_path: &str,
1815 fields: &[&str],
1816) -> Result<(), SolveErrorEnvelopeV1> {
1817 for field in fields {
1818 let value = required_value(object, field, parent_path)?;
1819 validate_number(value, &child_path(parent_path, field))?;
1820 }
1821 Ok(())
1822}
1823
1824fn validate_optional_numbers(
1825 object: &Map<String, Value>,
1826 parent_path: &str,
1827 fields: &[&str],
1828) -> Result<(), SolveErrorEnvelopeV1> {
1829 for field in fields {
1830 validate_optional_number(object, parent_path, field)?;
1831 }
1832 Ok(())
1833}
1834
1835fn validate_optional_number(
1836 object: &Map<String, Value>,
1837 parent_path: &str,
1838 field: &str,
1839) -> Result<(), SolveErrorEnvelopeV1> {
1840 if let Some(value) = object.get(field) {
1841 validate_number(value, &child_path(parent_path, field))?;
1842 }
1843 Ok(())
1844}
1845
1846fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1847 if value.is_number() {
1848 Ok(())
1849 } else {
1850 Err(protocol_error(
1851 SolveErrorCodeV1::InvalidValue,
1852 "expected a number",
1853 path,
1854 ))
1855 }
1856}
1857
1858fn validate_optional_booleans(
1859 object: &Map<String, Value>,
1860 parent_path: &str,
1861 fields: &[&str],
1862) -> Result<(), SolveErrorEnvelopeV1> {
1863 for field in fields {
1864 if let Some(value) = object.get(*field) {
1865 if !value.is_boolean() {
1866 return Err(protocol_error(
1867 SolveErrorCodeV1::InvalidValue,
1868 "expected a boolean",
1869 child_path(parent_path, field),
1870 ));
1871 }
1872 }
1873 }
1874 Ok(())
1875}
1876
1877fn child_path(parent: &str, field: &str) -> String {
1878 format!("{parent}.{field}")
1879}
1880
1881#[cfg(test)]
1883mod request_range_tests {
1884 use super::*;
1885
1886 fn valid_request_json() -> String {
1889 r#"{"schema_version":1,
1890 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1891 "drag_model":"G7","ballistic_coefficient":0.243},
1892 "rifle":{"muzzle_velocity_mps":823.0},
1893 "shot":{"max_range_m":1000.0},
1894 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1895 .to_string()
1896 }
1897
1898 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1899 decode_solve_request_v1(json).expect_err("request should have been rejected")
1900 }
1901
1902 #[test]
1903 fn an_ordinary_request_still_decodes() {
1904 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1905 }
1906
1907 #[test]
1913 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1914 let reproducer = r#"{"schema_version":1,
1915 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1916 "drag_model":"G7","ballistic_coefficient":1.2e2},
1917 "rifle":{"muzzle_velocity_mps":823.0},
1918 "shot":{"max_range_m":100.0},
1919 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1920
1921 let envelope = decode_err(reproducer);
1922 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1923 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1924 }
1925
1926 #[test]
1929 fn the_rejection_envelope_round_trips() {
1930 let envelope = decode_err(
1931 r#"{"schema_version":1,
1932 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1933 "drag_model":"G7","ballistic_coefficient":0.243},
1934 "rifle":{"muzzle_velocity_mps":823.0},
1935 "shot":{"max_range_m":100.0},
1936 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1937 );
1938 let encoded = serde_json::to_string(&envelope).expect("serialize");
1939 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1940 assert_eq!(decoded, envelope);
1941 }
1942
1943 #[test]
1944 fn each_bounded_field_reports_its_own_path() {
1945 for (field, bad_value, path) in [
1946 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1947 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1948 (
1949 "ballistic_coefficient",
1950 "1.0e6",
1951 "$.projectile.ballistic_coefficient",
1952 ),
1953 ("length_m", "1.0e-9", "$.projectile.length_m"),
1954 ] {
1955 let json = valid_request_json().replace(
1956 &format!("\"{field}\":{}", default_for(field)),
1957 &format!("\"{field}\":{bad_value}"),
1958 );
1959 let envelope = decode_err(&json);
1960 assert_eq!(
1961 envelope.error.path(),
1962 Some(path),
1963 "wrong path for {field}"
1964 );
1965 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1966 }
1967 }
1968
1969 fn default_for(field: &str) -> &'static str {
1970 match field {
1971 "mass_kg" => "0.01134",
1972 "diameter_m" => "0.00782",
1973 "ballistic_coefficient" => "0.243",
1974 "length_m" => "0.031",
1975 other => panic!("no default recorded for {other}"),
1976 }
1977 }
1978
1979 #[test]
1983 fn muzzle_velocity_is_deliberately_left_unbounded() {
1984 let json = valid_request_json().replace("823.0", "1.0e308");
1985 decode_solve_request_v1(&json)
1986 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1987 }
1988}