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 #[serde(
642 default,
643 skip_serializing_if = "Option::is_none",
644 deserialize_with = "deserialize_present"
645 )]
646 pub aerodynamic_jump: Option<bool>,
647}
648
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
657#[serde(rename_all = "snake_case")]
658pub enum WindShearModelV1 {
659 #[default]
662 None,
663 Logarithmic,
665 PowerLaw,
667 #[serde(alias = "ekman")]
672 EkmanSpiral,
673}
674
675impl WindShearModelV1 {
676 pub fn as_engine_str(self) -> &'static str {
681 match self {
682 WindShearModelV1::None => "none",
683 WindShearModelV1::Logarithmic => "logarithmic",
684 WindShearModelV1::PowerLaw => "power_law",
685 WindShearModelV1::EkmanSpiral => "ekman_spiral",
686 }
687 }
688
689 pub fn is_enabled(self) -> bool {
696 !matches!(self, WindShearModelV1::None)
697 }
698}
699
700#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
702#[serde(deny_unknown_fields)]
703pub struct SamplingV1 {
704 #[serde(
705 default,
706 skip_serializing_if = "Option::is_none",
707 deserialize_with = "deserialize_present"
708 )]
709 pub interval_m: Option<f64>,
710}
711
712#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
718#[serde(deny_unknown_fields)]
719pub struct ResolvedSolveRequestV1 {
720 pub schema_version: SchemaVersionV1,
721 pub projectile: ResolvedProjectileV1,
722 pub rifle: ResolvedRifleV1,
723 pub shot: ResolvedShotV1,
724 pub atmosphere: ResolvedAtmosphereV1,
725 pub wind: ResolvedWindV1,
726 pub solver: ResolvedSolverV1,
727 pub effects: ResolvedEffectsV1,
728 pub sampling: ResolvedSamplingV1,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub reticle: Option<ReticleRequestV1>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub corrections: Option<CorrectionsV1>,
741}
742
743#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745#[serde(deny_unknown_fields)]
746pub struct ResolvedProjectileV1 {
747 pub mass_kg: f64,
748 pub diameter_m: f64,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub length_m: Option<f64>,
751 pub drag_model: DragModelV1,
752 pub ballistic_coefficient: f64,
753}
754
755#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
757#[serde(deny_unknown_fields)]
758pub struct ResolvedRifleV1 {
759 pub muzzle_velocity_mps: f64,
760 pub sight_height_m: f64,
761 pub muzzle_height_m: f64,
762 pub twist_rate_m_per_turn: f64,
763 pub twist_direction: TwistDirectionV1,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub sight_offset_lateral_m: Option<f64>,
768}
769
770#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
782#[serde(deny_unknown_fields)]
783pub struct ResolvedShotV1 {
784 pub max_range_m: f64,
785 #[serde(default, skip_serializing_if = "Option::is_none")]
786 pub zero_distance_m: Option<f64>,
787 pub muzzle_angle_rad: f64,
788 pub aim_azimuth_rad: f64,
789 pub shot_azimuth_rad: f64,
790 pub shooting_angle_rad: f64,
791 pub cant_angle_rad: f64,
792 pub target_height_m: f64,
793 pub ground_threshold_m: f64,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub zero_poi_up_m: Option<f64>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
801 pub zero_poi_right_m: Option<f64>,
802 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub drops_reference: Option<DropsReferenceV1>,
806}
807
808#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
810#[serde(deny_unknown_fields)]
811pub struct ResolvedAtmosphereV1 {
812 pub altitude_m: f64,
813 pub temperature_k: f64,
814 pub pressure_pa: f64,
815 pub relative_humidity: f64,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub latitude_rad: Option<f64>,
818 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub pressure_reference: Option<PressureReferenceV1>,
822}
823
824#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
829#[serde(untagged)]
830pub enum ResolvedWindV1 {
831 Constant(ResolvedConstantWindV1),
832 Segmented(ResolvedSegmentedWindV1),
833}
834
835#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
837#[serde(deny_unknown_fields)]
838pub struct ResolvedConstantWindV1 {
839 pub speed_mps: f64,
840 pub direction_from_rad: f64,
841 pub vertical_speed_mps: f64,
842 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub wind_reference: Option<WindReferenceV1>,
847}
848
849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851#[serde(deny_unknown_fields)]
852pub struct ResolvedSegmentedWindV1 {
853 pub segments: Vec<ResolvedWindSegmentV1>,
854 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub wind_reference: Option<WindReferenceV1>,
859}
860
861#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
863#[serde(deny_unknown_fields)]
864pub struct ResolvedWindSegmentV1 {
865 pub until_distance_m: f64,
866 pub speed_mps: f64,
867 pub direction_from_rad: f64,
868 pub vertical_speed_mps: f64,
869}
870
871#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
873#[serde(deny_unknown_fields)]
874pub struct ResolvedSolverV1 {
875 pub method: SolverMethodV1,
876 pub time_step_s: f64,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields)]
882pub struct ResolvedEffectsV1 {
883 pub magnus: bool,
884 pub coriolis: bool,
885 pub enhanced_spin_drift: bool,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
890 pub wind_shear_model: Option<WindShearModelV1>,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
901 pub aerodynamic_jump: Option<bool>,
902}
903
904#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906#[serde(deny_unknown_fields)]
907pub struct ResolvedSamplingV1 {
908 pub interval_m: f64,
909}
910
911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
913#[serde(deny_unknown_fields)]
914pub struct SolveSuccessV1 {
915 pub schema_version: SchemaVersionV1,
916 pub engine_version: String,
917 pub status: SuccessStatusV1,
918 pub resolved_request: ResolvedSolveRequestV1,
919 #[serde(default)]
920 pub assumptions: Vec<SolveNoticeV1>,
921 #[serde(default)]
922 pub warnings: Vec<SolveNoticeV1>,
923 pub summary: SolveSummaryV1,
924 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
925 pub samples: Vec<TrajectorySampleV1>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub reticle_hold: Option<ReticleHoldV1>,
931}
932
933fn serialize_solve_samples_v1<S>(
934 samples: &[TrajectorySampleV1],
935 serializer: S,
936) -> Result<S::Ok, S::Error>
937where
938 S: Serializer,
939{
940 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
941 return Err(serde::ser::Error::custom(format_args!(
942 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
943 samples.len()
944 )));
945 }
946 samples.serialize(serializer)
947}
948
949impl SolveSuccessV1 {
950 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
956 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
957 return Ok(());
958 }
959
960 Err(SolveErrorEnvelopeV1::new(
961 SolveErrorV1::new(
962 SolveErrorCodeV1::ResourceLimit,
963 format!(
964 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
965 self.samples.len()
966 ),
967 )
968 .at_path("$.sampling.interval_m"),
969 ))
970 }
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
975#[serde(rename_all = "snake_case")]
976pub enum SuccessStatusV1 {
977 Ok,
978}
979
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
982#[serde(deny_unknown_fields)]
983pub struct SolveNoticeV1 {
984 pub code: String,
985 pub message: String,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub path: Option<String>,
988}
989
990#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
992#[serde(deny_unknown_fields)]
993pub struct SolveSummaryV1 {
994 pub actual_range_m: f64,
995 pub maximum_height_m: f64,
998 pub time_of_flight_s: f64,
999 pub terminal_speed_mps: f64,
1000 pub terminal_energy_j: f64,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub stability_factor: Option<f64>,
1005 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub spin_drift_m: Option<f64>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub equivalent_horizontal_range_m: Option<f64>,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub aerodynamic_jump_moa: Option<f64>,
1032 pub termination: TerminationReasonV1,
1033}
1034
1035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1037#[serde(rename_all = "snake_case")]
1038pub enum TerminationReasonV1 {
1039 MaxRange,
1040 GroundThreshold,
1041 TimeLimit,
1042 VelocityFloor,
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047#[serde(deny_unknown_fields)]
1048pub struct TrajectorySampleV1 {
1049 pub distance_m: f64,
1050 pub time_s: f64,
1051 pub speed_mps: f64,
1052 pub energy_j: f64,
1053 pub drop_m: f64,
1055 pub windage_m: f64,
1057 pub mach: f64,
1058 #[serde(default)]
1059 pub flags: Vec<SampleFlagV1>,
1060}
1061
1062#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1064#[serde(rename_all = "snake_case")]
1065pub enum SampleFlagV1 {
1066 Transonic,
1067 Subsonic,
1068 Terminal,
1069 GroundThreshold,
1070}
1071
1072#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1074#[serde(deny_unknown_fields)]
1075pub struct SolveErrorEnvelopeV1 {
1076 pub schema_version: SchemaVersionV1,
1077 pub status: ErrorStatusV1,
1078 pub error: SolveErrorV1,
1079}
1080
1081impl SolveErrorEnvelopeV1 {
1082 pub fn new(error: SolveErrorV1) -> Self {
1084 Self {
1085 schema_version: SchemaVersionV1,
1086 status: ErrorStatusV1::Error,
1087 error,
1088 }
1089 }
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1094#[serde(rename_all = "snake_case")]
1095pub enum ErrorStatusV1 {
1096 Error,
1097}
1098
1099#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct SolveErrorV1 {
1106 pub code: SolveErrorCodeV1,
1107 pub message: String,
1108 location: SolveErrorLocationV1,
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq)]
1112enum SolveErrorLocationV1 {
1113 None,
1114 Path(String),
1115 Source {
1116 line: NonZeroUsize,
1117 column: NonZeroUsize,
1118 },
1119}
1120
1121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1123pub enum SolveErrorLocationErrorV1 {
1124 ZeroLine,
1125 ZeroColumn,
1126}
1127
1128impl fmt::Display for SolveErrorLocationErrorV1 {
1129 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1130 match self {
1131 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1132 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1133 }
1134 }
1135}
1136
1137impl std::error::Error for SolveErrorLocationErrorV1 {}
1138
1139impl SolveErrorV1 {
1140 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1142 Self {
1143 code,
1144 message: message.into(),
1145 location: SolveErrorLocationV1::None,
1146 }
1147 }
1148
1149 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1151 self.location = SolveErrorLocationV1::Path(path.into());
1152 self
1153 }
1154
1155 pub fn at_location(
1157 mut self,
1158 line: usize,
1159 column: usize,
1160 ) -> Result<Self, SolveErrorLocationErrorV1> {
1161 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1162 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1163 self.location = SolveErrorLocationV1::Source { line, column };
1164 Ok(self)
1165 }
1166
1167 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1172 self.location = SolveErrorLocationV1::Source {
1173 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1174 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1175 };
1176 self
1177 }
1178
1179 pub fn path(&self) -> Option<&str> {
1181 match &self.location {
1182 SolveErrorLocationV1::Path(path) => Some(path),
1183 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1184 }
1185 }
1186
1187 pub fn line(&self) -> Option<usize> {
1189 match &self.location {
1190 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1191 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1192 }
1193 }
1194
1195 pub fn column(&self) -> Option<usize> {
1197 match &self.location {
1198 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1199 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1200 }
1201 }
1202}
1203
1204impl Serialize for SolveErrorV1 {
1205 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1206 where
1207 S: Serializer,
1208 {
1209 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1210 state.serialize_field("code", &self.code)?;
1211 state.serialize_field("message", &self.message)?;
1212 state.serialize_field("path", &self.path())?;
1213 state.serialize_field("line", &self.line())?;
1214 state.serialize_field("column", &self.column())?;
1215 state.end()
1216 }
1217}
1218
1219impl<'de> Deserialize<'de> for SolveErrorV1 {
1220 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1221 where
1222 D: Deserializer<'de>,
1223 {
1224 #[derive(Deserialize)]
1225 #[serde(deny_unknown_fields)]
1226 struct SolveErrorWireV1 {
1227 code: SolveErrorCodeV1,
1228 message: String,
1229 path: Option<String>,
1230 line: Option<usize>,
1231 column: Option<usize>,
1232 }
1233
1234 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1235 let location = match (wire.path, wire.line, wire.column) {
1236 (None, None, None) => SolveErrorLocationV1::None,
1237 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1238 (None, Some(line), Some(column)) => {
1239 let line = NonZeroUsize::new(line)
1240 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1241 let column = NonZeroUsize::new(column)
1242 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1243 SolveErrorLocationV1::Source { line, column }
1244 }
1245 _ => {
1246 return Err(de::Error::custom(
1247 "error location must contain either path or both line and column",
1248 ));
1249 }
1250 };
1251
1252 Ok(Self {
1253 code: wire.code,
1254 message: wire.message,
1255 location,
1256 })
1257 }
1258}
1259
1260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "snake_case")]
1263pub enum SolveErrorCodeV1 {
1264 InvalidJson,
1265 UnsupportedSchemaVersion,
1266 UnknownField,
1267 MissingField,
1268 InvalidValue,
1269 ConflictingFields,
1270 ResourceLimit,
1271 SolveFailed,
1272 IoError,
1273 InternalError,
1274}
1275
1276pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1283 let value: Value = serde_json::from_str(input).map_err(|error| {
1284 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1285 .at_parser_location(error.line(), error.column());
1286 envelope(error)
1287 })?;
1288
1289 validate_request_shape(&value)?;
1290
1291 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1292 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1293 })?;
1294
1295 validate_request_ranges(&request)?;
1296
1297 Ok(request)
1298}
1299
1300mod limits {
1313 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1317 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1319 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1321 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1323
1324 }
1331
1332fn require_range(
1333 value: f64,
1334 (min, max): (f64, f64),
1335 path: &str,
1336) -> Result<(), SolveErrorEnvelopeV1> {
1337 if !value.is_finite() {
1338 return Err(protocol_error(
1339 SolveErrorCodeV1::InvalidValue,
1340 format!("{path} must be a finite number"),
1341 path,
1342 ));
1343 }
1344 if value < min || value > max {
1345 return Err(protocol_error(
1346 SolveErrorCodeV1::InvalidValue,
1347 format!("{path} must be between {min} and {max}, got {value}"),
1348 path,
1349 ));
1350 }
1351 Ok(())
1352}
1353
1354fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1359 require_range(
1360 request.projectile.mass_kg,
1361 limits::MASS_KG,
1362 "$.projectile.mass_kg",
1363 )?;
1364 require_range(
1365 request.projectile.diameter_m,
1366 limits::DIAMETER_M,
1367 "$.projectile.diameter_m",
1368 )?;
1369 require_range(
1370 request.projectile.ballistic_coefficient,
1371 limits::BALLISTIC_COEFFICIENT,
1372 "$.projectile.ballistic_coefficient",
1373 )?;
1374 if let Some(length_m) = request.projectile.length_m {
1375 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1376 }
1377 Ok(())
1378}
1379
1380fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1381 SolveErrorEnvelopeV1::new(error)
1382}
1383
1384fn protocol_error(
1385 code: SolveErrorCodeV1,
1386 message: impl Into<String>,
1387 path: impl Into<String>,
1388) -> SolveErrorEnvelopeV1 {
1389 envelope(SolveErrorV1::new(code, message).at_path(path))
1390}
1391
1392fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1393 let root = require_object(value, "$")?;
1394
1395 validate_schema_version(root)?;
1399 validate_members(
1400 root,
1401 "$",
1402 &[
1403 "schema_version",
1404 "projectile",
1405 "rifle",
1406 "shot",
1407 "atmosphere",
1408 "wind",
1409 "solver",
1410 "effects",
1411 "sampling",
1412 "reticle",
1414 "corrections",
1416 ],
1417 &[
1418 "schema_version",
1419 "projectile",
1420 "rifle",
1421 "shot",
1422 "atmosphere",
1423 "wind",
1424 "solver",
1425 "effects",
1426 "sampling",
1427 ],
1428 )?;
1429
1430 validate_projectile(required_value(root, "projectile", "$")?)?;
1431 validate_rifle(required_value(root, "rifle", "$")?)?;
1432 validate_shot(required_value(root, "shot", "$")?)?;
1433 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1434 validate_wind(required_value(root, "wind", "$")?)?;
1435 validate_solver(required_value(root, "solver", "$")?)?;
1436 validate_effects(required_value(root, "effects", "$")?)?;
1437 validate_sampling(required_value(root, "sampling", "$")?)?;
1438 if let Some(reticle) = root.get("reticle") {
1439 validate_reticle(reticle)?;
1440 }
1441 if let Some(corrections) = root.get("corrections") {
1442 validate_corrections(corrections)?;
1443 }
1444 Ok(())
1445}
1446
1447fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1452 let path = "$.corrections";
1453 let object = require_object(value, path)?;
1454 validate_members(object, path, &["bc5d_table_path"], &[])?;
1455 if let Some(table_path) = object.get("bc5d_table_path") {
1456 if !table_path.is_string() {
1457 return Err(protocol_error(
1458 SolveErrorCodeV1::InvalidValue,
1459 "expected a string",
1460 "$.corrections.bc5d_table_path",
1461 ));
1462 }
1463 }
1464 Ok(())
1465}
1466
1467fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1474 let path = "$.reticle";
1475 let object = require_object(value, path)?;
1476 validate_members(
1477 object,
1478 path,
1479 &["range_m", "magnification", "description"],
1480 &["range_m", "magnification", "description"],
1481 )?;
1482 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1483 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1484 Ok(())
1485}
1486
1487fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1488 let value = required_value(root, "schema_version", "$")?;
1489 let version = if let Some(version) = value.as_i64() {
1490 i128::from(version)
1491 } else if let Some(version) = value.as_u64() {
1492 i128::from(version)
1493 } else {
1494 return Err(protocol_error(
1495 SolveErrorCodeV1::InvalidValue,
1496 "schema_version must be the integer 1",
1497 "$.schema_version",
1498 ));
1499 };
1500 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1501 return Err(protocol_error(
1502 SolveErrorCodeV1::UnsupportedSchemaVersion,
1503 format!(
1504 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1505 ),
1506 "$.schema_version",
1507 ));
1508 }
1509 Ok(())
1510}
1511
1512fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1513 let path = "$.projectile";
1514 let object = require_object(value, path)?;
1515 validate_members(
1516 object,
1517 path,
1518 &[
1519 "mass_kg",
1520 "diameter_m",
1521 "length_m",
1522 "drag_model",
1523 "ballistic_coefficient",
1524 ],
1525 &[
1526 "mass_kg",
1527 "diameter_m",
1528 "drag_model",
1529 "ballistic_coefficient",
1530 ],
1531 )?;
1532 validate_required_numbers(
1533 object,
1534 path,
1535 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1536 )?;
1537 validate_optional_number(object, path, "length_m")?;
1538 validate_string_enum(
1539 required_value(object, "drag_model", path)?,
1540 "$.projectile.drag_model",
1541 &DRAG_MODEL_WIRE_NAMES_V1,
1542 )
1543}
1544
1545fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1546 let path = "$.rifle";
1547 let object = require_object(value, path)?;
1548 validate_members(
1549 object,
1550 path,
1551 &[
1552 "muzzle_velocity_mps",
1553 "sight_height_m",
1554 "muzzle_height_m",
1555 "twist_rate_m_per_turn",
1556 "twist_direction",
1557 "sight_offset_lateral_m",
1558 ],
1559 &["muzzle_velocity_mps"],
1560 )?;
1561 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1562 validate_optional_numbers(
1563 object,
1564 path,
1565 &[
1566 "sight_height_m",
1567 "muzzle_height_m",
1568 "twist_rate_m_per_turn",
1569 "sight_offset_lateral_m",
1570 ],
1571 )?;
1572 if let Some(direction) = object.get("twist_direction") {
1573 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1574 }
1575 Ok(())
1576}
1577
1578fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1579 let path = "$.shot";
1580 let object = require_object(value, path)?;
1581 validate_members(
1582 object,
1583 path,
1584 &[
1585 "max_range_m",
1586 "zero_distance_m",
1587 "muzzle_angle_rad",
1588 "aim_azimuth_rad",
1589 "shot_azimuth_rad",
1590 "shooting_angle_rad",
1591 "cant_angle_rad",
1592 "target_height_m",
1593 "ground_threshold_m",
1594 "zero_poi_up_m",
1595 "zero_poi_right_m",
1596 "drops_reference",
1597 ],
1598 &["max_range_m"],
1599 )?;
1600 validate_required_numbers(object, path, &["max_range_m"])?;
1601 validate_optional_number(object, path, "zero_distance_m")?;
1602 validate_optional_number(object, path, "muzzle_angle_rad")?;
1603 validate_optional_numbers(
1604 object,
1605 path,
1606 &[
1607 "aim_azimuth_rad",
1608 "shot_azimuth_rad",
1609 "shooting_angle_rad",
1610 "cant_angle_rad",
1611 "target_height_m",
1612 "ground_threshold_m",
1613 "zero_poi_up_m",
1614 "zero_poi_right_m",
1615 ],
1616 )?;
1617 if let Some(reference) = object.get("drops_reference") {
1620 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1621 }
1622 Ok(())
1623}
1624
1625fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1626 let path = "$.atmosphere";
1627 let object = require_object(value, path)?;
1628 validate_members(
1629 object,
1630 path,
1631 &[
1632 "altitude_m",
1633 "temperature_k",
1634 "pressure_pa",
1635 "pressure_reference",
1636 "relative_humidity",
1637 "latitude_rad",
1638 ],
1639 &[],
1640 )?;
1641 validate_optional_numbers(
1642 object,
1643 path,
1644 &[
1645 "altitude_m",
1646 "temperature_k",
1647 "pressure_pa",
1648 "relative_humidity",
1649 "latitude_rad",
1650 ],
1651 )?;
1652 if let Some(reference) = object.get("pressure_reference") {
1658 validate_string_enum(
1659 reference,
1660 "$.atmosphere.pressure_reference",
1661 &["absolute", "qnh"],
1662 )?;
1663 }
1664 Ok(())
1665}
1666
1667fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1668 let path = "$.wind";
1669 let object = require_object(value, path)?;
1670 validate_members(
1671 object,
1672 path,
1673 &[
1674 "speed_mps",
1675 "direction_from_rad",
1676 "vertical_speed_mps",
1677 "segments",
1678 "wind_reference",
1679 ],
1680 &[],
1681 )?;
1682 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1683 validate_optional_number(object, path, field)?;
1684 }
1685 if let Some(reference) = object.get("wind_reference") {
1687 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1688 }
1689
1690 if let Some(segments) = object.get("segments") {
1691 let Some(segments) = segments.as_array() else {
1692 return Err(protocol_error(
1693 SolveErrorCodeV1::InvalidValue,
1694 "segments must be an array",
1695 "$.wind.segments",
1696 ));
1697 };
1698 for (index, segment) in segments.iter().enumerate() {
1699 let segment_path = format!("$.wind.segments[{index}]");
1700 let segment = require_object(segment, &segment_path)?;
1701 validate_members(
1702 segment,
1703 &segment_path,
1704 &[
1705 "until_distance_m",
1706 "speed_mps",
1707 "direction_from_rad",
1708 "vertical_speed_mps",
1709 ],
1710 &["until_distance_m", "speed_mps", "direction_from_rad"],
1711 )?;
1712 validate_required_numbers(
1713 segment,
1714 &segment_path,
1715 &["until_distance_m", "speed_mps", "direction_from_rad"],
1716 )?;
1717 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1718 }
1719 }
1720 Ok(())
1721}
1722
1723fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1724 let path = "$.solver";
1725 let object = require_object(value, path)?;
1726 validate_members(object, path, &["method", "time_step_s"], &[])?;
1727 validate_optional_number(object, path, "time_step_s")?;
1728 if let Some(method) = object.get("method") {
1729 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1730 }
1731 Ok(())
1732}
1733
1734pub const SOLVE_JSON_EFFECT_NAMES_V1: &[&str] = &[
1743 "magnus",
1744 "coriolis",
1745 "enhanced_spin_drift",
1746 "wind_shear_model",
1747 "aerodynamic_jump",
1748];
1749
1750fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1751 let path = "$.effects";
1752 let object = require_object(value, path)?;
1753 validate_members(object, path, SOLVE_JSON_EFFECT_NAMES_V1, &[])?;
1754 validate_optional_booleans(
1755 object,
1756 path,
1757 &[
1758 "magnus",
1759 "coriolis",
1760 "enhanced_spin_drift",
1761 "aerodynamic_jump",
1762 ],
1763 )?;
1764
1765 if let Some(model) = object.get("wind_shear_model") {
1769 validate_string_enum(
1770 model,
1771 "$.effects.wind_shear_model",
1772 &[
1773 "none",
1774 "logarithmic",
1775 "power_law",
1776 "ekman_spiral",
1777 "ekman",
1779 ],
1780 )?;
1781 }
1782
1783 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1784 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1785 {
1786 return Err(protocol_error(
1787 SolveErrorCodeV1::ConflictingFields,
1788 "magnus and enhanced_spin_drift cannot both be enabled",
1789 "$.effects",
1790 ));
1791 }
1792
1793 Ok(())
1794}
1795
1796fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1797 let path = "$.sampling";
1798 let object = require_object(value, path)?;
1799 validate_members(object, path, &["interval_m"], &[])?;
1800 validate_optional_number(object, path, "interval_m")
1801}
1802
1803fn require_object<'a>(
1804 value: &'a Value,
1805 path: &str,
1806) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1807 value
1808 .as_object()
1809 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1810}
1811
1812fn required_value<'a>(
1813 object: &'a Map<String, Value>,
1814 field: &str,
1815 parent_path: &str,
1816) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1817 object.get(field).ok_or_else(|| {
1818 protocol_error(
1819 SolveErrorCodeV1::MissingField,
1820 format!("missing required field `{field}`"),
1821 child_path(parent_path, field),
1822 )
1823 })
1824}
1825
1826fn validate_members(
1827 object: &Map<String, Value>,
1828 path: &str,
1829 allowed: &[&str],
1830 required: &[&str],
1831) -> Result<(), SolveErrorEnvelopeV1> {
1832 if let Some(field) = object
1833 .keys()
1834 .find(|field| !allowed.contains(&field.as_str()))
1835 {
1836 return Err(protocol_error(
1837 SolveErrorCodeV1::UnknownField,
1838 format!("unknown field `{field}`"),
1839 child_path(path, field),
1840 ));
1841 }
1842
1843 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1844 return Err(protocol_error(
1845 SolveErrorCodeV1::MissingField,
1846 format!("missing required field `{field}`"),
1847 child_path(path, field),
1848 ));
1849 }
1850 Ok(())
1851}
1852
1853fn validate_string_enum(
1854 value: &Value,
1855 path: &str,
1856 allowed: &[&str],
1857) -> Result<(), SolveErrorEnvelopeV1> {
1858 let Some(value) = value.as_str() else {
1859 return Err(protocol_error(
1860 SolveErrorCodeV1::InvalidValue,
1861 "expected a string enum value",
1862 path,
1863 ));
1864 };
1865 if !allowed.contains(&value) {
1866 return Err(protocol_error(
1867 SolveErrorCodeV1::InvalidValue,
1868 format!(
1869 "invalid value `{value}`; expected one of {}",
1870 allowed.join(", ")
1871 ),
1872 path,
1873 ));
1874 }
1875 Ok(())
1876}
1877
1878fn validate_required_numbers(
1879 object: &Map<String, Value>,
1880 parent_path: &str,
1881 fields: &[&str],
1882) -> Result<(), SolveErrorEnvelopeV1> {
1883 for field in fields {
1884 let value = required_value(object, field, parent_path)?;
1885 validate_number(value, &child_path(parent_path, field))?;
1886 }
1887 Ok(())
1888}
1889
1890fn validate_optional_numbers(
1891 object: &Map<String, Value>,
1892 parent_path: &str,
1893 fields: &[&str],
1894) -> Result<(), SolveErrorEnvelopeV1> {
1895 for field in fields {
1896 validate_optional_number(object, parent_path, field)?;
1897 }
1898 Ok(())
1899}
1900
1901fn validate_optional_number(
1902 object: &Map<String, Value>,
1903 parent_path: &str,
1904 field: &str,
1905) -> Result<(), SolveErrorEnvelopeV1> {
1906 if let Some(value) = object.get(field) {
1907 validate_number(value, &child_path(parent_path, field))?;
1908 }
1909 Ok(())
1910}
1911
1912fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1913 if value.is_number() {
1914 Ok(())
1915 } else {
1916 Err(protocol_error(
1917 SolveErrorCodeV1::InvalidValue,
1918 "expected a number",
1919 path,
1920 ))
1921 }
1922}
1923
1924fn validate_optional_booleans(
1925 object: &Map<String, Value>,
1926 parent_path: &str,
1927 fields: &[&str],
1928) -> Result<(), SolveErrorEnvelopeV1> {
1929 for field in fields {
1930 if let Some(value) = object.get(*field) {
1931 if !value.is_boolean() {
1932 return Err(protocol_error(
1933 SolveErrorCodeV1::InvalidValue,
1934 "expected a boolean",
1935 child_path(parent_path, field),
1936 ));
1937 }
1938 }
1939 }
1940 Ok(())
1941}
1942
1943fn child_path(parent: &str, field: &str) -> String {
1944 format!("{parent}.{field}")
1945}
1946
1947#[cfg(test)]
1949mod request_range_tests {
1950 use super::*;
1951
1952 fn valid_request_json() -> String {
1955 r#"{"schema_version":1,
1956 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1957 "drag_model":"G7","ballistic_coefficient":0.243},
1958 "rifle":{"muzzle_velocity_mps":823.0},
1959 "shot":{"max_range_m":1000.0},
1960 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1961 .to_string()
1962 }
1963
1964 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1965 decode_solve_request_v1(json).expect_err("request should have been rejected")
1966 }
1967
1968 #[test]
1969 fn an_ordinary_request_still_decodes() {
1970 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1971 }
1972
1973 #[test]
1979 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1980 let reproducer = r#"{"schema_version":1,
1981 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1982 "drag_model":"G7","ballistic_coefficient":1.2e2},
1983 "rifle":{"muzzle_velocity_mps":823.0},
1984 "shot":{"max_range_m":100.0},
1985 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1986
1987 let envelope = decode_err(reproducer);
1988 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1989 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1990 }
1991
1992 #[test]
1995 fn the_rejection_envelope_round_trips() {
1996 let envelope = decode_err(
1997 r#"{"schema_version":1,
1998 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1999 "drag_model":"G7","ballistic_coefficient":0.243},
2000 "rifle":{"muzzle_velocity_mps":823.0},
2001 "shot":{"max_range_m":100.0},
2002 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
2003 );
2004 let encoded = serde_json::to_string(&envelope).expect("serialize");
2005 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
2006 assert_eq!(decoded, envelope);
2007 }
2008
2009 #[test]
2010 fn each_bounded_field_reports_its_own_path() {
2011 for (field, bad_value, path) in [
2012 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
2013 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
2014 (
2015 "ballistic_coefficient",
2016 "1.0e6",
2017 "$.projectile.ballistic_coefficient",
2018 ),
2019 ("length_m", "1.0e-9", "$.projectile.length_m"),
2020 ] {
2021 let json = valid_request_json().replace(
2022 &format!("\"{field}\":{}", default_for(field)),
2023 &format!("\"{field}\":{bad_value}"),
2024 );
2025 let envelope = decode_err(&json);
2026 assert_eq!(
2027 envelope.error.path(),
2028 Some(path),
2029 "wrong path for {field}"
2030 );
2031 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
2032 }
2033 }
2034
2035 fn default_for(field: &str) -> &'static str {
2036 match field {
2037 "mass_kg" => "0.01134",
2038 "diameter_m" => "0.00782",
2039 "ballistic_coefficient" => "0.243",
2040 "length_m" => "0.031",
2041 other => panic!("no default recorded for {other}"),
2042 }
2043 }
2044
2045 #[test]
2049 fn muzzle_velocity_is_deliberately_left_unbounded() {
2050 let json = valid_request_json().replace("823.0", "1.0e308");
2051 decode_solve_request_v1(&json)
2052 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
2053 }
2054}