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}
611
612#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
614#[serde(deny_unknown_fields)]
615pub struct SamplingV1 {
616 #[serde(
617 default,
618 skip_serializing_if = "Option::is_none",
619 deserialize_with = "deserialize_present"
620 )]
621 pub interval_m: Option<f64>,
622}
623
624#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct ResolvedSolveRequestV1 {
632 pub schema_version: SchemaVersionV1,
633 pub projectile: ResolvedProjectileV1,
634 pub rifle: ResolvedRifleV1,
635 pub shot: ResolvedShotV1,
636 pub atmosphere: ResolvedAtmosphereV1,
637 pub wind: ResolvedWindV1,
638 pub solver: ResolvedSolverV1,
639 pub effects: ResolvedEffectsV1,
640 pub sampling: ResolvedSamplingV1,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub reticle: Option<ReticleRequestV1>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub corrections: Option<CorrectionsV1>,
653}
654
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657#[serde(deny_unknown_fields)]
658pub struct ResolvedProjectileV1 {
659 pub mass_kg: f64,
660 pub diameter_m: f64,
661 #[serde(default, skip_serializing_if = "Option::is_none")]
662 pub length_m: Option<f64>,
663 pub drag_model: DragModelV1,
664 pub ballistic_coefficient: f64,
665}
666
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct ResolvedRifleV1 {
671 pub muzzle_velocity_mps: f64,
672 pub sight_height_m: f64,
673 pub muzzle_height_m: f64,
674 pub twist_rate_m_per_turn: f64,
675 pub twist_direction: TwistDirectionV1,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub sight_offset_lateral_m: Option<f64>,
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
694#[serde(deny_unknown_fields)]
695pub struct ResolvedShotV1 {
696 pub max_range_m: f64,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub zero_distance_m: Option<f64>,
699 pub muzzle_angle_rad: f64,
700 pub aim_azimuth_rad: f64,
701 pub shot_azimuth_rad: f64,
702 pub shooting_angle_rad: f64,
703 pub cant_angle_rad: f64,
704 pub target_height_m: f64,
705 pub ground_threshold_m: f64,
706 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub zero_poi_up_m: Option<f64>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub zero_poi_right_m: Option<f64>,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub drops_reference: Option<DropsReferenceV1>,
718}
719
720#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
722#[serde(deny_unknown_fields)]
723pub struct ResolvedAtmosphereV1 {
724 pub altitude_m: f64,
725 pub temperature_k: f64,
726 pub pressure_pa: f64,
727 pub relative_humidity: f64,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub latitude_rad: Option<f64>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub pressure_reference: Option<PressureReferenceV1>,
734}
735
736#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
741#[serde(untagged)]
742pub enum ResolvedWindV1 {
743 Constant(ResolvedConstantWindV1),
744 Segmented(ResolvedSegmentedWindV1),
745}
746
747#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
749#[serde(deny_unknown_fields)]
750pub struct ResolvedConstantWindV1 {
751 pub speed_mps: f64,
752 pub direction_from_rad: f64,
753 pub vertical_speed_mps: f64,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub wind_reference: Option<WindReferenceV1>,
759}
760
761#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
763#[serde(deny_unknown_fields)]
764pub struct ResolvedSegmentedWindV1 {
765 pub segments: Vec<ResolvedWindSegmentV1>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub wind_reference: Option<WindReferenceV1>,
771}
772
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
775#[serde(deny_unknown_fields)]
776pub struct ResolvedWindSegmentV1 {
777 pub until_distance_m: f64,
778 pub speed_mps: f64,
779 pub direction_from_rad: f64,
780 pub vertical_speed_mps: f64,
781}
782
783#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
785#[serde(deny_unknown_fields)]
786pub struct ResolvedSolverV1 {
787 pub method: SolverMethodV1,
788 pub time_step_s: f64,
789}
790
791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(deny_unknown_fields)]
794pub struct ResolvedEffectsV1 {
795 pub magnus: bool,
796 pub coriolis: bool,
797 pub enhanced_spin_drift: bool,
798}
799
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
802#[serde(deny_unknown_fields)]
803pub struct ResolvedSamplingV1 {
804 pub interval_m: f64,
805}
806
807#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
809#[serde(deny_unknown_fields)]
810pub struct SolveSuccessV1 {
811 pub schema_version: SchemaVersionV1,
812 pub engine_version: String,
813 pub status: SuccessStatusV1,
814 pub resolved_request: ResolvedSolveRequestV1,
815 #[serde(default)]
816 pub assumptions: Vec<SolveNoticeV1>,
817 #[serde(default)]
818 pub warnings: Vec<SolveNoticeV1>,
819 pub summary: SolveSummaryV1,
820 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
821 pub samples: Vec<TrajectorySampleV1>,
822 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub reticle_hold: Option<ReticleHoldV1>,
827}
828
829fn serialize_solve_samples_v1<S>(
830 samples: &[TrajectorySampleV1],
831 serializer: S,
832) -> Result<S::Ok, S::Error>
833where
834 S: Serializer,
835{
836 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
837 return Err(serde::ser::Error::custom(format_args!(
838 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
839 samples.len()
840 )));
841 }
842 samples.serialize(serializer)
843}
844
845impl SolveSuccessV1 {
846 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
852 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
853 return Ok(());
854 }
855
856 Err(SolveErrorEnvelopeV1::new(
857 SolveErrorV1::new(
858 SolveErrorCodeV1::ResourceLimit,
859 format!(
860 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
861 self.samples.len()
862 ),
863 )
864 .at_path("$.sampling.interval_m"),
865 ))
866 }
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
871#[serde(rename_all = "snake_case")]
872pub enum SuccessStatusV1 {
873 Ok,
874}
875
876#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
878#[serde(deny_unknown_fields)]
879pub struct SolveNoticeV1 {
880 pub code: String,
881 pub message: String,
882 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub path: Option<String>,
884}
885
886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct SolveSummaryV1 {
890 pub actual_range_m: f64,
891 pub maximum_height_m: f64,
894 pub time_of_flight_s: f64,
895 pub terminal_speed_mps: f64,
896 pub terminal_energy_j: f64,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub stability_factor: Option<f64>,
901 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub spin_drift_m: Option<f64>,
906 #[serde(default, skip_serializing_if = "Option::is_none")]
914 pub equivalent_horizontal_range_m: Option<f64>,
915 pub termination: TerminationReasonV1,
916}
917
918#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
920#[serde(rename_all = "snake_case")]
921pub enum TerminationReasonV1 {
922 MaxRange,
923 GroundThreshold,
924 TimeLimit,
925 VelocityFloor,
926}
927
928#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct TrajectorySampleV1 {
932 pub distance_m: f64,
933 pub time_s: f64,
934 pub speed_mps: f64,
935 pub energy_j: f64,
936 pub drop_m: f64,
938 pub windage_m: f64,
940 pub mach: f64,
941 #[serde(default)]
942 pub flags: Vec<SampleFlagV1>,
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(rename_all = "snake_case")]
948pub enum SampleFlagV1 {
949 Transonic,
950 Subsonic,
951 Terminal,
952 GroundThreshold,
953}
954
955#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
957#[serde(deny_unknown_fields)]
958pub struct SolveErrorEnvelopeV1 {
959 pub schema_version: SchemaVersionV1,
960 pub status: ErrorStatusV1,
961 pub error: SolveErrorV1,
962}
963
964impl SolveErrorEnvelopeV1 {
965 pub fn new(error: SolveErrorV1) -> Self {
967 Self {
968 schema_version: SchemaVersionV1,
969 status: ErrorStatusV1::Error,
970 error,
971 }
972 }
973}
974
975#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
977#[serde(rename_all = "snake_case")]
978pub enum ErrorStatusV1 {
979 Error,
980}
981
982#[derive(Debug, Clone, PartialEq, Eq)]
988pub struct SolveErrorV1 {
989 pub code: SolveErrorCodeV1,
990 pub message: String,
991 location: SolveErrorLocationV1,
992}
993
994#[derive(Debug, Clone, PartialEq, Eq)]
995enum SolveErrorLocationV1 {
996 None,
997 Path(String),
998 Source {
999 line: NonZeroUsize,
1000 column: NonZeroUsize,
1001 },
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1006pub enum SolveErrorLocationErrorV1 {
1007 ZeroLine,
1008 ZeroColumn,
1009}
1010
1011impl fmt::Display for SolveErrorLocationErrorV1 {
1012 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1013 match self {
1014 Self::ZeroLine => formatter.write_str("error line must be one-based"),
1015 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
1016 }
1017 }
1018}
1019
1020impl std::error::Error for SolveErrorLocationErrorV1 {}
1021
1022impl SolveErrorV1 {
1023 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
1025 Self {
1026 code,
1027 message: message.into(),
1028 location: SolveErrorLocationV1::None,
1029 }
1030 }
1031
1032 pub fn at_path(mut self, path: impl Into<String>) -> Self {
1034 self.location = SolveErrorLocationV1::Path(path.into());
1035 self
1036 }
1037
1038 pub fn at_location(
1040 mut self,
1041 line: usize,
1042 column: usize,
1043 ) -> Result<Self, SolveErrorLocationErrorV1> {
1044 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1045 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1046 self.location = SolveErrorLocationV1::Source { line, column };
1047 Ok(self)
1048 }
1049
1050 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1055 self.location = SolveErrorLocationV1::Source {
1056 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1057 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1058 };
1059 self
1060 }
1061
1062 pub fn path(&self) -> Option<&str> {
1064 match &self.location {
1065 SolveErrorLocationV1::Path(path) => Some(path),
1066 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1067 }
1068 }
1069
1070 pub fn line(&self) -> Option<usize> {
1072 match &self.location {
1073 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1074 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1075 }
1076 }
1077
1078 pub fn column(&self) -> Option<usize> {
1080 match &self.location {
1081 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1082 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1083 }
1084 }
1085}
1086
1087impl Serialize for SolveErrorV1 {
1088 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1089 where
1090 S: Serializer,
1091 {
1092 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1093 state.serialize_field("code", &self.code)?;
1094 state.serialize_field("message", &self.message)?;
1095 state.serialize_field("path", &self.path())?;
1096 state.serialize_field("line", &self.line())?;
1097 state.serialize_field("column", &self.column())?;
1098 state.end()
1099 }
1100}
1101
1102impl<'de> Deserialize<'de> for SolveErrorV1 {
1103 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1104 where
1105 D: Deserializer<'de>,
1106 {
1107 #[derive(Deserialize)]
1108 #[serde(deny_unknown_fields)]
1109 struct SolveErrorWireV1 {
1110 code: SolveErrorCodeV1,
1111 message: String,
1112 path: Option<String>,
1113 line: Option<usize>,
1114 column: Option<usize>,
1115 }
1116
1117 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1118 let location = match (wire.path, wire.line, wire.column) {
1119 (None, None, None) => SolveErrorLocationV1::None,
1120 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1121 (None, Some(line), Some(column)) => {
1122 let line = NonZeroUsize::new(line)
1123 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1124 let column = NonZeroUsize::new(column)
1125 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1126 SolveErrorLocationV1::Source { line, column }
1127 }
1128 _ => {
1129 return Err(de::Error::custom(
1130 "error location must contain either path or both line and column",
1131 ));
1132 }
1133 };
1134
1135 Ok(Self {
1136 code: wire.code,
1137 message: wire.message,
1138 location,
1139 })
1140 }
1141}
1142
1143#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1145#[serde(rename_all = "snake_case")]
1146pub enum SolveErrorCodeV1 {
1147 InvalidJson,
1148 UnsupportedSchemaVersion,
1149 UnknownField,
1150 MissingField,
1151 InvalidValue,
1152 ConflictingFields,
1153 ResourceLimit,
1154 SolveFailed,
1155 IoError,
1156 InternalError,
1157}
1158
1159pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1166 let value: Value = serde_json::from_str(input).map_err(|error| {
1167 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1168 .at_parser_location(error.line(), error.column());
1169 envelope(error)
1170 })?;
1171
1172 validate_request_shape(&value)?;
1173
1174 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1175 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1176 })?;
1177
1178 validate_request_ranges(&request)?;
1179
1180 Ok(request)
1181}
1182
1183mod limits {
1196 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1200 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1202 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1204 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1206
1207 }
1214
1215fn require_range(
1216 value: f64,
1217 (min, max): (f64, f64),
1218 path: &str,
1219) -> Result<(), SolveErrorEnvelopeV1> {
1220 if !value.is_finite() {
1221 return Err(protocol_error(
1222 SolveErrorCodeV1::InvalidValue,
1223 format!("{path} must be a finite number"),
1224 path,
1225 ));
1226 }
1227 if value < min || value > max {
1228 return Err(protocol_error(
1229 SolveErrorCodeV1::InvalidValue,
1230 format!("{path} must be between {min} and {max}, got {value}"),
1231 path,
1232 ));
1233 }
1234 Ok(())
1235}
1236
1237fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1242 require_range(
1243 request.projectile.mass_kg,
1244 limits::MASS_KG,
1245 "$.projectile.mass_kg",
1246 )?;
1247 require_range(
1248 request.projectile.diameter_m,
1249 limits::DIAMETER_M,
1250 "$.projectile.diameter_m",
1251 )?;
1252 require_range(
1253 request.projectile.ballistic_coefficient,
1254 limits::BALLISTIC_COEFFICIENT,
1255 "$.projectile.ballistic_coefficient",
1256 )?;
1257 if let Some(length_m) = request.projectile.length_m {
1258 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1259 }
1260 Ok(())
1261}
1262
1263fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1264 SolveErrorEnvelopeV1::new(error)
1265}
1266
1267fn protocol_error(
1268 code: SolveErrorCodeV1,
1269 message: impl Into<String>,
1270 path: impl Into<String>,
1271) -> SolveErrorEnvelopeV1 {
1272 envelope(SolveErrorV1::new(code, message).at_path(path))
1273}
1274
1275fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1276 let root = require_object(value, "$")?;
1277
1278 validate_schema_version(root)?;
1282 validate_members(
1283 root,
1284 "$",
1285 &[
1286 "schema_version",
1287 "projectile",
1288 "rifle",
1289 "shot",
1290 "atmosphere",
1291 "wind",
1292 "solver",
1293 "effects",
1294 "sampling",
1295 "reticle",
1297 "corrections",
1299 ],
1300 &[
1301 "schema_version",
1302 "projectile",
1303 "rifle",
1304 "shot",
1305 "atmosphere",
1306 "wind",
1307 "solver",
1308 "effects",
1309 "sampling",
1310 ],
1311 )?;
1312
1313 validate_projectile(required_value(root, "projectile", "$")?)?;
1314 validate_rifle(required_value(root, "rifle", "$")?)?;
1315 validate_shot(required_value(root, "shot", "$")?)?;
1316 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1317 validate_wind(required_value(root, "wind", "$")?)?;
1318 validate_solver(required_value(root, "solver", "$")?)?;
1319 validate_effects(required_value(root, "effects", "$")?)?;
1320 validate_sampling(required_value(root, "sampling", "$")?)?;
1321 if let Some(reticle) = root.get("reticle") {
1322 validate_reticle(reticle)?;
1323 }
1324 if let Some(corrections) = root.get("corrections") {
1325 validate_corrections(corrections)?;
1326 }
1327 Ok(())
1328}
1329
1330fn validate_corrections(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1335 let path = "$.corrections";
1336 let object = require_object(value, path)?;
1337 validate_members(object, path, &["bc5d_table_path"], &[])?;
1338 if let Some(table_path) = object.get("bc5d_table_path") {
1339 if !table_path.is_string() {
1340 return Err(protocol_error(
1341 SolveErrorCodeV1::InvalidValue,
1342 "expected a string",
1343 "$.corrections.bc5d_table_path",
1344 ));
1345 }
1346 }
1347 Ok(())
1348}
1349
1350fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1357 let path = "$.reticle";
1358 let object = require_object(value, path)?;
1359 validate_members(
1360 object,
1361 path,
1362 &["range_m", "magnification", "description"],
1363 &["range_m", "magnification", "description"],
1364 )?;
1365 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1366 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1367 Ok(())
1368}
1369
1370fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1371 let value = required_value(root, "schema_version", "$")?;
1372 let version = if let Some(version) = value.as_i64() {
1373 i128::from(version)
1374 } else if let Some(version) = value.as_u64() {
1375 i128::from(version)
1376 } else {
1377 return Err(protocol_error(
1378 SolveErrorCodeV1::InvalidValue,
1379 "schema_version must be the integer 1",
1380 "$.schema_version",
1381 ));
1382 };
1383 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1384 return Err(protocol_error(
1385 SolveErrorCodeV1::UnsupportedSchemaVersion,
1386 format!(
1387 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1388 ),
1389 "$.schema_version",
1390 ));
1391 }
1392 Ok(())
1393}
1394
1395fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1396 let path = "$.projectile";
1397 let object = require_object(value, path)?;
1398 validate_members(
1399 object,
1400 path,
1401 &[
1402 "mass_kg",
1403 "diameter_m",
1404 "length_m",
1405 "drag_model",
1406 "ballistic_coefficient",
1407 ],
1408 &[
1409 "mass_kg",
1410 "diameter_m",
1411 "drag_model",
1412 "ballistic_coefficient",
1413 ],
1414 )?;
1415 validate_required_numbers(
1416 object,
1417 path,
1418 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1419 )?;
1420 validate_optional_number(object, path, "length_m")?;
1421 validate_string_enum(
1422 required_value(object, "drag_model", path)?,
1423 "$.projectile.drag_model",
1424 &DRAG_MODEL_WIRE_NAMES_V1,
1425 )
1426}
1427
1428fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1429 let path = "$.rifle";
1430 let object = require_object(value, path)?;
1431 validate_members(
1432 object,
1433 path,
1434 &[
1435 "muzzle_velocity_mps",
1436 "sight_height_m",
1437 "muzzle_height_m",
1438 "twist_rate_m_per_turn",
1439 "twist_direction",
1440 "sight_offset_lateral_m",
1441 ],
1442 &["muzzle_velocity_mps"],
1443 )?;
1444 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1445 validate_optional_numbers(
1446 object,
1447 path,
1448 &[
1449 "sight_height_m",
1450 "muzzle_height_m",
1451 "twist_rate_m_per_turn",
1452 "sight_offset_lateral_m",
1453 ],
1454 )?;
1455 if let Some(direction) = object.get("twist_direction") {
1456 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1457 }
1458 Ok(())
1459}
1460
1461fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1462 let path = "$.shot";
1463 let object = require_object(value, path)?;
1464 validate_members(
1465 object,
1466 path,
1467 &[
1468 "max_range_m",
1469 "zero_distance_m",
1470 "muzzle_angle_rad",
1471 "aim_azimuth_rad",
1472 "shot_azimuth_rad",
1473 "shooting_angle_rad",
1474 "cant_angle_rad",
1475 "target_height_m",
1476 "ground_threshold_m",
1477 "zero_poi_up_m",
1478 "zero_poi_right_m",
1479 "drops_reference",
1480 ],
1481 &["max_range_m"],
1482 )?;
1483 validate_required_numbers(object, path, &["max_range_m"])?;
1484 validate_optional_number(object, path, "zero_distance_m")?;
1485 validate_optional_number(object, path, "muzzle_angle_rad")?;
1486 validate_optional_numbers(
1487 object,
1488 path,
1489 &[
1490 "aim_azimuth_rad",
1491 "shot_azimuth_rad",
1492 "shooting_angle_rad",
1493 "cant_angle_rad",
1494 "target_height_m",
1495 "ground_threshold_m",
1496 "zero_poi_up_m",
1497 "zero_poi_right_m",
1498 ],
1499 )?;
1500 if let Some(reference) = object.get("drops_reference") {
1503 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1504 }
1505 Ok(())
1506}
1507
1508fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1509 let path = "$.atmosphere";
1510 let object = require_object(value, path)?;
1511 validate_members(
1512 object,
1513 path,
1514 &[
1515 "altitude_m",
1516 "temperature_k",
1517 "pressure_pa",
1518 "pressure_reference",
1519 "relative_humidity",
1520 "latitude_rad",
1521 ],
1522 &[],
1523 )?;
1524 validate_optional_numbers(
1525 object,
1526 path,
1527 &[
1528 "altitude_m",
1529 "temperature_k",
1530 "pressure_pa",
1531 "relative_humidity",
1532 "latitude_rad",
1533 ],
1534 )?;
1535 if let Some(reference) = object.get("pressure_reference") {
1541 validate_string_enum(
1542 reference,
1543 "$.atmosphere.pressure_reference",
1544 &["absolute", "qnh"],
1545 )?;
1546 }
1547 Ok(())
1548}
1549
1550fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1551 let path = "$.wind";
1552 let object = require_object(value, path)?;
1553 validate_members(
1554 object,
1555 path,
1556 &[
1557 "speed_mps",
1558 "direction_from_rad",
1559 "vertical_speed_mps",
1560 "segments",
1561 "wind_reference",
1562 ],
1563 &[],
1564 )?;
1565 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1566 validate_optional_number(object, path, field)?;
1567 }
1568 if let Some(reference) = object.get("wind_reference") {
1570 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1571 }
1572
1573 if let Some(segments) = object.get("segments") {
1574 let Some(segments) = segments.as_array() else {
1575 return Err(protocol_error(
1576 SolveErrorCodeV1::InvalidValue,
1577 "segments must be an array",
1578 "$.wind.segments",
1579 ));
1580 };
1581 for (index, segment) in segments.iter().enumerate() {
1582 let segment_path = format!("$.wind.segments[{index}]");
1583 let segment = require_object(segment, &segment_path)?;
1584 validate_members(
1585 segment,
1586 &segment_path,
1587 &[
1588 "until_distance_m",
1589 "speed_mps",
1590 "direction_from_rad",
1591 "vertical_speed_mps",
1592 ],
1593 &["until_distance_m", "speed_mps", "direction_from_rad"],
1594 )?;
1595 validate_required_numbers(
1596 segment,
1597 &segment_path,
1598 &["until_distance_m", "speed_mps", "direction_from_rad"],
1599 )?;
1600 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1601 }
1602 }
1603 Ok(())
1604}
1605
1606fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1607 let path = "$.solver";
1608 let object = require_object(value, path)?;
1609 validate_members(object, path, &["method", "time_step_s"], &[])?;
1610 validate_optional_number(object, path, "time_step_s")?;
1611 if let Some(method) = object.get("method") {
1612 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1613 }
1614 Ok(())
1615}
1616
1617fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1618 let path = "$.effects";
1619 let object = require_object(value, path)?;
1620 validate_members(
1621 object,
1622 path,
1623 &["magnus", "coriolis", "enhanced_spin_drift"],
1624 &[],
1625 )?;
1626 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1627
1628 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1629 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1630 {
1631 return Err(protocol_error(
1632 SolveErrorCodeV1::ConflictingFields,
1633 "magnus and enhanced_spin_drift cannot both be enabled",
1634 "$.effects",
1635 ));
1636 }
1637
1638 Ok(())
1639}
1640
1641fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1642 let path = "$.sampling";
1643 let object = require_object(value, path)?;
1644 validate_members(object, path, &["interval_m"], &[])?;
1645 validate_optional_number(object, path, "interval_m")
1646}
1647
1648fn require_object<'a>(
1649 value: &'a Value,
1650 path: &str,
1651) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1652 value
1653 .as_object()
1654 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1655}
1656
1657fn required_value<'a>(
1658 object: &'a Map<String, Value>,
1659 field: &str,
1660 parent_path: &str,
1661) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1662 object.get(field).ok_or_else(|| {
1663 protocol_error(
1664 SolveErrorCodeV1::MissingField,
1665 format!("missing required field `{field}`"),
1666 child_path(parent_path, field),
1667 )
1668 })
1669}
1670
1671fn validate_members(
1672 object: &Map<String, Value>,
1673 path: &str,
1674 allowed: &[&str],
1675 required: &[&str],
1676) -> Result<(), SolveErrorEnvelopeV1> {
1677 if let Some(field) = object
1678 .keys()
1679 .find(|field| !allowed.contains(&field.as_str()))
1680 {
1681 return Err(protocol_error(
1682 SolveErrorCodeV1::UnknownField,
1683 format!("unknown field `{field}`"),
1684 child_path(path, field),
1685 ));
1686 }
1687
1688 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1689 return Err(protocol_error(
1690 SolveErrorCodeV1::MissingField,
1691 format!("missing required field `{field}`"),
1692 child_path(path, field),
1693 ));
1694 }
1695 Ok(())
1696}
1697
1698fn validate_string_enum(
1699 value: &Value,
1700 path: &str,
1701 allowed: &[&str],
1702) -> Result<(), SolveErrorEnvelopeV1> {
1703 let Some(value) = value.as_str() else {
1704 return Err(protocol_error(
1705 SolveErrorCodeV1::InvalidValue,
1706 "expected a string enum value",
1707 path,
1708 ));
1709 };
1710 if !allowed.contains(&value) {
1711 return Err(protocol_error(
1712 SolveErrorCodeV1::InvalidValue,
1713 format!(
1714 "invalid value `{value}`; expected one of {}",
1715 allowed.join(", ")
1716 ),
1717 path,
1718 ));
1719 }
1720 Ok(())
1721}
1722
1723fn validate_required_numbers(
1724 object: &Map<String, Value>,
1725 parent_path: &str,
1726 fields: &[&str],
1727) -> Result<(), SolveErrorEnvelopeV1> {
1728 for field in fields {
1729 let value = required_value(object, field, parent_path)?;
1730 validate_number(value, &child_path(parent_path, field))?;
1731 }
1732 Ok(())
1733}
1734
1735fn validate_optional_numbers(
1736 object: &Map<String, Value>,
1737 parent_path: &str,
1738 fields: &[&str],
1739) -> Result<(), SolveErrorEnvelopeV1> {
1740 for field in fields {
1741 validate_optional_number(object, parent_path, field)?;
1742 }
1743 Ok(())
1744}
1745
1746fn validate_optional_number(
1747 object: &Map<String, Value>,
1748 parent_path: &str,
1749 field: &str,
1750) -> Result<(), SolveErrorEnvelopeV1> {
1751 if let Some(value) = object.get(field) {
1752 validate_number(value, &child_path(parent_path, field))?;
1753 }
1754 Ok(())
1755}
1756
1757fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1758 if value.is_number() {
1759 Ok(())
1760 } else {
1761 Err(protocol_error(
1762 SolveErrorCodeV1::InvalidValue,
1763 "expected a number",
1764 path,
1765 ))
1766 }
1767}
1768
1769fn validate_optional_booleans(
1770 object: &Map<String, Value>,
1771 parent_path: &str,
1772 fields: &[&str],
1773) -> Result<(), SolveErrorEnvelopeV1> {
1774 for field in fields {
1775 if let Some(value) = object.get(*field) {
1776 if !value.is_boolean() {
1777 return Err(protocol_error(
1778 SolveErrorCodeV1::InvalidValue,
1779 "expected a boolean",
1780 child_path(parent_path, field),
1781 ));
1782 }
1783 }
1784 }
1785 Ok(())
1786}
1787
1788fn child_path(parent: &str, field: &str) -> String {
1789 format!("{parent}.{field}")
1790}
1791
1792#[cfg(test)]
1794mod request_range_tests {
1795 use super::*;
1796
1797 fn valid_request_json() -> String {
1800 r#"{"schema_version":1,
1801 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1802 "drag_model":"G7","ballistic_coefficient":0.243},
1803 "rifle":{"muzzle_velocity_mps":823.0},
1804 "shot":{"max_range_m":1000.0},
1805 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1806 .to_string()
1807 }
1808
1809 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1810 decode_solve_request_v1(json).expect_err("request should have been rejected")
1811 }
1812
1813 #[test]
1814 fn an_ordinary_request_still_decodes() {
1815 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1816 }
1817
1818 #[test]
1824 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1825 let reproducer = r#"{"schema_version":1,
1826 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1827 "drag_model":"G7","ballistic_coefficient":1.2e2},
1828 "rifle":{"muzzle_velocity_mps":823.0},
1829 "shot":{"max_range_m":100.0},
1830 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1831
1832 let envelope = decode_err(reproducer);
1833 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1834 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1835 }
1836
1837 #[test]
1840 fn the_rejection_envelope_round_trips() {
1841 let envelope = decode_err(
1842 r#"{"schema_version":1,
1843 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1844 "drag_model":"G7","ballistic_coefficient":0.243},
1845 "rifle":{"muzzle_velocity_mps":823.0},
1846 "shot":{"max_range_m":100.0},
1847 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1848 );
1849 let encoded = serde_json::to_string(&envelope).expect("serialize");
1850 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1851 assert_eq!(decoded, envelope);
1852 }
1853
1854 #[test]
1855 fn each_bounded_field_reports_its_own_path() {
1856 for (field, bad_value, path) in [
1857 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1858 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1859 (
1860 "ballistic_coefficient",
1861 "1.0e6",
1862 "$.projectile.ballistic_coefficient",
1863 ),
1864 ("length_m", "1.0e-9", "$.projectile.length_m"),
1865 ] {
1866 let json = valid_request_json().replace(
1867 &format!("\"{field}\":{}", default_for(field)),
1868 &format!("\"{field}\":{bad_value}"),
1869 );
1870 let envelope = decode_err(&json);
1871 assert_eq!(
1872 envelope.error.path(),
1873 Some(path),
1874 "wrong path for {field}"
1875 );
1876 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1877 }
1878 }
1879
1880 fn default_for(field: &str) -> &'static str {
1881 match field {
1882 "mass_kg" => "0.01134",
1883 "diameter_m" => "0.00782",
1884 "ballistic_coefficient" => "0.243",
1885 "length_m" => "0.031",
1886 other => panic!("no default recorded for {other}"),
1887 }
1888 }
1889
1890 #[test]
1894 fn muzzle_velocity_is_deliberately_left_unbounded() {
1895 let json = valid_request_json().replace("823.0", "1.0e308");
1896 decode_solve_request_v1(&json)
1897 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1898 }
1899}