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}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct ReticleRequestV1 {
153 pub range_m: f64,
155 pub magnification: f64,
158 pub description: crate::reticle::ReticleDescription,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct ReticleHoldV1 {
168 pub range_m: f64,
170 pub magnification: f64,
172 pub down_mil: f64,
173 pub right_mil: f64,
174 pub mark_scale: f64,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub nearest_mark_index: Option<usize>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub nearest_mark_label: Option<String>,
184 pub nearest_mark_distance_mil: f64,
185 pub off_reticle: bool,
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct ProjectileV1 {
194 pub mass_kg: f64,
195 pub diameter_m: f64,
196 #[serde(
197 default,
198 skip_serializing_if = "Option::is_none",
199 deserialize_with = "deserialize_present"
200 )]
201 pub length_m: Option<f64>,
202 pub drag_model: DragModelV1,
203 pub ballistic_coefficient: f64,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208pub enum DragModelV1 {
209 #[serde(rename = "G1")]
210 G1,
211 #[serde(rename = "G6")]
212 G6,
213 #[serde(rename = "G7")]
214 G7,
215 #[serde(rename = "G8")]
216 G8,
217 #[serde(rename = "G2")]
218 G2,
219 #[serde(rename = "G5")]
220 G5,
221 #[serde(rename = "GI")]
222 GI,
223 #[serde(rename = "GS")]
224 GS,
225 #[serde(rename = "RA4")]
226 RA4,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct RifleV1 {
233 pub muzzle_velocity_mps: f64,
234 #[serde(
235 default,
236 skip_serializing_if = "Option::is_none",
237 deserialize_with = "deserialize_present"
238 )]
239 pub sight_height_m: Option<f64>,
240 #[serde(
241 default,
242 skip_serializing_if = "Option::is_none",
243 deserialize_with = "deserialize_present"
244 )]
245 pub muzzle_height_m: Option<f64>,
246 #[serde(
247 default,
248 skip_serializing_if = "Option::is_none",
249 deserialize_with = "deserialize_present"
250 )]
251 pub twist_rate_m_per_turn: Option<f64>,
252 #[serde(
253 default,
254 skip_serializing_if = "Option::is_none",
255 deserialize_with = "deserialize_present"
256 )]
257 pub twist_direction: Option<TwistDirectionV1>,
258 #[serde(
266 default,
267 skip_serializing_if = "Option::is_none",
268 deserialize_with = "deserialize_present"
269 )]
270 pub sight_offset_lateral_m: Option<f64>,
271}
272
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum TwistDirectionV1 {
277 Left,
278 #[default]
279 Right,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct ShotV1 {
286 pub max_range_m: f64,
287 #[serde(
288 default,
289 skip_serializing_if = "Option::is_none",
290 deserialize_with = "deserialize_present"
291 )]
292 pub zero_distance_m: Option<f64>,
293 #[serde(
294 default,
295 skip_serializing_if = "Option::is_none",
296 deserialize_with = "deserialize_present"
297 )]
298 pub muzzle_angle_rad: Option<f64>,
299 #[serde(
300 default,
301 skip_serializing_if = "Option::is_none",
302 deserialize_with = "deserialize_present"
303 )]
304 pub aim_azimuth_rad: Option<f64>,
305 #[serde(
306 default,
307 skip_serializing_if = "Option::is_none",
308 deserialize_with = "deserialize_present"
309 )]
310 pub shot_azimuth_rad: Option<f64>,
311 #[serde(
312 default,
313 skip_serializing_if = "Option::is_none",
314 deserialize_with = "deserialize_present"
315 )]
316 pub shooting_angle_rad: Option<f64>,
317 #[serde(
318 default,
319 skip_serializing_if = "Option::is_none",
320 deserialize_with = "deserialize_present"
321 )]
322 pub cant_angle_rad: Option<f64>,
323 #[serde(
324 default,
325 skip_serializing_if = "Option::is_none",
326 deserialize_with = "deserialize_present"
327 )]
328 pub target_height_m: Option<f64>,
329 #[serde(
330 default,
331 skip_serializing_if = "Option::is_none",
332 deserialize_with = "deserialize_present"
333 )]
334 pub ground_threshold_m: Option<f64>,
335 #[serde(
341 default,
342 skip_serializing_if = "Option::is_none",
343 deserialize_with = "deserialize_present"
344 )]
345 pub zero_poi_up_m: Option<f64>,
346 #[serde(
350 default,
351 skip_serializing_if = "Option::is_none",
352 deserialize_with = "deserialize_present"
353 )]
354 pub zero_poi_right_m: Option<f64>,
355 #[serde(
363 default,
364 skip_serializing_if = "Option::is_none",
365 deserialize_with = "deserialize_present"
366 )]
367 pub drops_reference: Option<DropsReferenceV1>,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
372#[serde(rename_all = "snake_case")]
373pub enum DropsReferenceV1 {
374 #[default]
376 Los,
377 Target,
380}
381
382#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
384#[serde(deny_unknown_fields)]
385pub struct AtmosphereV1 {
386 #[serde(
387 default,
388 skip_serializing_if = "Option::is_none",
389 deserialize_with = "deserialize_present"
390 )]
391 pub altitude_m: Option<f64>,
392 #[serde(
395 default,
396 skip_serializing_if = "Option::is_none",
397 deserialize_with = "deserialize_present"
398 )]
399 pub temperature_k: Option<f64>,
400 #[serde(
406 default,
407 skip_serializing_if = "Option::is_none",
408 deserialize_with = "deserialize_present"
409 )]
410 pub pressure_pa: Option<f64>,
411 #[serde(
417 default,
418 skip_serializing_if = "Option::is_none",
419 deserialize_with = "deserialize_present"
420 )]
421 pub pressure_reference: Option<PressureReferenceV1>,
422 #[serde(
423 default,
424 skip_serializing_if = "Option::is_none",
425 deserialize_with = "deserialize_present"
426 )]
427 pub relative_humidity: Option<f64>,
428 #[serde(
429 default,
430 skip_serializing_if = "Option::is_none",
431 deserialize_with = "deserialize_present"
432 )]
433 pub latitude_rad: Option<f64>,
434}
435
436#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "snake_case")]
441pub enum PressureReferenceV1 {
442 #[default]
443 Absolute,
444 Qnh,
445}
446
447#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
452#[serde(deny_unknown_fields)]
453pub struct WindV1 {
454 #[serde(
455 default,
456 skip_serializing_if = "Option::is_none",
457 deserialize_with = "deserialize_present"
458 )]
459 pub speed_mps: Option<f64>,
460 #[serde(
461 default,
462 skip_serializing_if = "Option::is_none",
463 deserialize_with = "deserialize_present"
464 )]
465 pub direction_from_rad: Option<f64>,
466 #[serde(
467 default,
468 skip_serializing_if = "Option::is_none",
469 deserialize_with = "deserialize_present"
470 )]
471 pub vertical_speed_mps: Option<f64>,
472 #[serde(
473 default,
474 skip_serializing_if = "Option::is_none",
475 deserialize_with = "deserialize_present"
476 )]
477 pub segments: Option<Vec<WindSegmentV1>>,
478 #[serde(
491 default,
492 skip_serializing_if = "Option::is_none",
493 deserialize_with = "deserialize_present"
494 )]
495 pub wind_reference: Option<WindReferenceV1>,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
500#[serde(rename_all = "snake_case")]
501pub enum WindReferenceV1 {
502 #[default]
504 Shooter,
505 Compass,
507}
508
509#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511#[serde(deny_unknown_fields)]
512pub struct WindSegmentV1 {
513 pub until_distance_m: f64,
514 pub speed_mps: f64,
515 pub direction_from_rad: f64,
516 #[serde(
517 default,
518 skip_serializing_if = "Option::is_none",
519 deserialize_with = "deserialize_present"
520 )]
521 pub vertical_speed_mps: Option<f64>,
522}
523
524#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
526#[serde(deny_unknown_fields)]
527pub struct SolverV1 {
528 #[serde(
529 default,
530 skip_serializing_if = "Option::is_none",
531 deserialize_with = "deserialize_present"
532 )]
533 pub method: Option<SolverMethodV1>,
534 #[serde(
536 default,
537 skip_serializing_if = "Option::is_none",
538 deserialize_with = "deserialize_present"
539 )]
540 pub time_step_s: Option<f64>,
541}
542
543#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
545#[serde(rename_all = "snake_case")]
546pub enum SolverMethodV1 {
547 Euler,
548 Rk4,
549 #[default]
550 Rk45,
551}
552
553#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(deny_unknown_fields)]
556pub struct EffectsV1 {
557 #[serde(
558 default,
559 skip_serializing_if = "Option::is_none",
560 deserialize_with = "deserialize_present"
561 )]
562 pub magnus: Option<bool>,
563 #[serde(
564 default,
565 skip_serializing_if = "Option::is_none",
566 deserialize_with = "deserialize_present"
567 )]
568 pub coriolis: Option<bool>,
569 #[serde(
570 default,
571 skip_serializing_if = "Option::is_none",
572 deserialize_with = "deserialize_present"
573 )]
574 pub enhanced_spin_drift: Option<bool>,
575}
576
577#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct SamplingV1 {
581 #[serde(
582 default,
583 skip_serializing_if = "Option::is_none",
584 deserialize_with = "deserialize_present"
585 )]
586 pub interval_m: Option<f64>,
587}
588
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
595#[serde(deny_unknown_fields)]
596pub struct ResolvedSolveRequestV1 {
597 pub schema_version: SchemaVersionV1,
598 pub projectile: ResolvedProjectileV1,
599 pub rifle: ResolvedRifleV1,
600 pub shot: ResolvedShotV1,
601 pub atmosphere: ResolvedAtmosphereV1,
602 pub wind: ResolvedWindV1,
603 pub solver: ResolvedSolverV1,
604 pub effects: ResolvedEffectsV1,
605 pub sampling: ResolvedSamplingV1,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub reticle: Option<ReticleRequestV1>,
611}
612
613#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct ResolvedProjectileV1 {
617 pub mass_kg: f64,
618 pub diameter_m: f64,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub length_m: Option<f64>,
621 pub drag_model: DragModelV1,
622 pub ballistic_coefficient: f64,
623}
624
625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
627#[serde(deny_unknown_fields)]
628pub struct ResolvedRifleV1 {
629 pub muzzle_velocity_mps: f64,
630 pub sight_height_m: f64,
631 pub muzzle_height_m: f64,
632 pub twist_rate_m_per_turn: f64,
633 pub twist_direction: TwistDirectionV1,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub sight_offset_lateral_m: Option<f64>,
638}
639
640#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
652#[serde(deny_unknown_fields)]
653pub struct ResolvedShotV1 {
654 pub max_range_m: f64,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub zero_distance_m: Option<f64>,
657 pub muzzle_angle_rad: f64,
658 pub aim_azimuth_rad: f64,
659 pub shot_azimuth_rad: f64,
660 pub shooting_angle_rad: f64,
661 pub cant_angle_rad: f64,
662 pub target_height_m: f64,
663 pub ground_threshold_m: f64,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub zero_poi_up_m: Option<f64>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub zero_poi_right_m: Option<f64>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub drops_reference: Option<DropsReferenceV1>,
676}
677
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680#[serde(deny_unknown_fields)]
681pub struct ResolvedAtmosphereV1 {
682 pub altitude_m: f64,
683 pub temperature_k: f64,
684 pub pressure_pa: f64,
685 pub relative_humidity: f64,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub latitude_rad: Option<f64>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub pressure_reference: Option<PressureReferenceV1>,
692}
693
694#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
699#[serde(untagged)]
700pub enum ResolvedWindV1 {
701 Constant(ResolvedConstantWindV1),
702 Segmented(ResolvedSegmentedWindV1),
703}
704
705#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
707#[serde(deny_unknown_fields)]
708pub struct ResolvedConstantWindV1 {
709 pub speed_mps: f64,
710 pub direction_from_rad: f64,
711 pub vertical_speed_mps: f64,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub wind_reference: Option<WindReferenceV1>,
717}
718
719#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
721#[serde(deny_unknown_fields)]
722pub struct ResolvedSegmentedWindV1 {
723 pub segments: Vec<ResolvedWindSegmentV1>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub wind_reference: Option<WindReferenceV1>,
729}
730
731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
733#[serde(deny_unknown_fields)]
734pub struct ResolvedWindSegmentV1 {
735 pub until_distance_m: f64,
736 pub speed_mps: f64,
737 pub direction_from_rad: f64,
738 pub vertical_speed_mps: f64,
739}
740
741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(deny_unknown_fields)]
744pub struct ResolvedSolverV1 {
745 pub method: SolverMethodV1,
746 pub time_step_s: f64,
747}
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(deny_unknown_fields)]
752pub struct ResolvedEffectsV1 {
753 pub magnus: bool,
754 pub coriolis: bool,
755 pub enhanced_spin_drift: bool,
756}
757
758#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760#[serde(deny_unknown_fields)]
761pub struct ResolvedSamplingV1 {
762 pub interval_m: f64,
763}
764
765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
767#[serde(deny_unknown_fields)]
768pub struct SolveSuccessV1 {
769 pub schema_version: SchemaVersionV1,
770 pub engine_version: String,
771 pub status: SuccessStatusV1,
772 pub resolved_request: ResolvedSolveRequestV1,
773 #[serde(default)]
774 pub assumptions: Vec<SolveNoticeV1>,
775 #[serde(default)]
776 pub warnings: Vec<SolveNoticeV1>,
777 pub summary: SolveSummaryV1,
778 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
779 pub samples: Vec<TrajectorySampleV1>,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
784 pub reticle_hold: Option<ReticleHoldV1>,
785}
786
787fn serialize_solve_samples_v1<S>(
788 samples: &[TrajectorySampleV1],
789 serializer: S,
790) -> Result<S::Ok, S::Error>
791where
792 S: Serializer,
793{
794 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
795 return Err(serde::ser::Error::custom(format_args!(
796 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
797 samples.len()
798 )));
799 }
800 samples.serialize(serializer)
801}
802
803impl SolveSuccessV1 {
804 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
810 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
811 return Ok(());
812 }
813
814 Err(SolveErrorEnvelopeV1::new(
815 SolveErrorV1::new(
816 SolveErrorCodeV1::ResourceLimit,
817 format!(
818 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
819 self.samples.len()
820 ),
821 )
822 .at_path("$.sampling.interval_m"),
823 ))
824 }
825}
826
827#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(rename_all = "snake_case")]
830pub enum SuccessStatusV1 {
831 Ok,
832}
833
834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(deny_unknown_fields)]
837pub struct SolveNoticeV1 {
838 pub code: String,
839 pub message: String,
840 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub path: Option<String>,
842}
843
844#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
846#[serde(deny_unknown_fields)]
847pub struct SolveSummaryV1 {
848 pub actual_range_m: f64,
849 pub maximum_height_m: f64,
852 pub time_of_flight_s: f64,
853 pub terminal_speed_mps: f64,
854 pub terminal_energy_j: f64,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub stability_factor: Option<f64>,
859 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub spin_drift_m: Option<f64>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub equivalent_horizontal_range_m: Option<f64>,
873 pub termination: TerminationReasonV1,
874}
875
876#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
878#[serde(rename_all = "snake_case")]
879pub enum TerminationReasonV1 {
880 MaxRange,
881 GroundThreshold,
882 TimeLimit,
883 VelocityFloor,
884}
885
886#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct TrajectorySampleV1 {
890 pub distance_m: f64,
891 pub time_s: f64,
892 pub speed_mps: f64,
893 pub energy_j: f64,
894 pub drop_m: f64,
896 pub windage_m: f64,
898 pub mach: f64,
899 #[serde(default)]
900 pub flags: Vec<SampleFlagV1>,
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
905#[serde(rename_all = "snake_case")]
906pub enum SampleFlagV1 {
907 Transonic,
908 Subsonic,
909 Terminal,
910 GroundThreshold,
911}
912
913#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct SolveErrorEnvelopeV1 {
917 pub schema_version: SchemaVersionV1,
918 pub status: ErrorStatusV1,
919 pub error: SolveErrorV1,
920}
921
922impl SolveErrorEnvelopeV1 {
923 pub fn new(error: SolveErrorV1) -> Self {
925 Self {
926 schema_version: SchemaVersionV1,
927 status: ErrorStatusV1::Error,
928 error,
929 }
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
935#[serde(rename_all = "snake_case")]
936pub enum ErrorStatusV1 {
937 Error,
938}
939
940#[derive(Debug, Clone, PartialEq, Eq)]
946pub struct SolveErrorV1 {
947 pub code: SolveErrorCodeV1,
948 pub message: String,
949 location: SolveErrorLocationV1,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq)]
953enum SolveErrorLocationV1 {
954 None,
955 Path(String),
956 Source {
957 line: NonZeroUsize,
958 column: NonZeroUsize,
959 },
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum SolveErrorLocationErrorV1 {
965 ZeroLine,
966 ZeroColumn,
967}
968
969impl fmt::Display for SolveErrorLocationErrorV1 {
970 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
971 match self {
972 Self::ZeroLine => formatter.write_str("error line must be one-based"),
973 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
974 }
975 }
976}
977
978impl std::error::Error for SolveErrorLocationErrorV1 {}
979
980impl SolveErrorV1 {
981 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
983 Self {
984 code,
985 message: message.into(),
986 location: SolveErrorLocationV1::None,
987 }
988 }
989
990 pub fn at_path(mut self, path: impl Into<String>) -> Self {
992 self.location = SolveErrorLocationV1::Path(path.into());
993 self
994 }
995
996 pub fn at_location(
998 mut self,
999 line: usize,
1000 column: usize,
1001 ) -> Result<Self, SolveErrorLocationErrorV1> {
1002 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
1003 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
1004 self.location = SolveErrorLocationV1::Source { line, column };
1005 Ok(self)
1006 }
1007
1008 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
1013 self.location = SolveErrorLocationV1::Source {
1014 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
1015 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
1016 };
1017 self
1018 }
1019
1020 pub fn path(&self) -> Option<&str> {
1022 match &self.location {
1023 SolveErrorLocationV1::Path(path) => Some(path),
1024 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1025 }
1026 }
1027
1028 pub fn line(&self) -> Option<usize> {
1030 match &self.location {
1031 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1032 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1033 }
1034 }
1035
1036 pub fn column(&self) -> Option<usize> {
1038 match &self.location {
1039 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1040 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1041 }
1042 }
1043}
1044
1045impl Serialize for SolveErrorV1 {
1046 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1047 where
1048 S: Serializer,
1049 {
1050 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1051 state.serialize_field("code", &self.code)?;
1052 state.serialize_field("message", &self.message)?;
1053 state.serialize_field("path", &self.path())?;
1054 state.serialize_field("line", &self.line())?;
1055 state.serialize_field("column", &self.column())?;
1056 state.end()
1057 }
1058}
1059
1060impl<'de> Deserialize<'de> for SolveErrorV1 {
1061 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1062 where
1063 D: Deserializer<'de>,
1064 {
1065 #[derive(Deserialize)]
1066 #[serde(deny_unknown_fields)]
1067 struct SolveErrorWireV1 {
1068 code: SolveErrorCodeV1,
1069 message: String,
1070 path: Option<String>,
1071 line: Option<usize>,
1072 column: Option<usize>,
1073 }
1074
1075 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1076 let location = match (wire.path, wire.line, wire.column) {
1077 (None, None, None) => SolveErrorLocationV1::None,
1078 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1079 (None, Some(line), Some(column)) => {
1080 let line = NonZeroUsize::new(line)
1081 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1082 let column = NonZeroUsize::new(column)
1083 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1084 SolveErrorLocationV1::Source { line, column }
1085 }
1086 _ => {
1087 return Err(de::Error::custom(
1088 "error location must contain either path or both line and column",
1089 ));
1090 }
1091 };
1092
1093 Ok(Self {
1094 code: wire.code,
1095 message: wire.message,
1096 location,
1097 })
1098 }
1099}
1100
1101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1103#[serde(rename_all = "snake_case")]
1104pub enum SolveErrorCodeV1 {
1105 InvalidJson,
1106 UnsupportedSchemaVersion,
1107 UnknownField,
1108 MissingField,
1109 InvalidValue,
1110 ConflictingFields,
1111 ResourceLimit,
1112 SolveFailed,
1113 IoError,
1114 InternalError,
1115}
1116
1117pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1124 let value: Value = serde_json::from_str(input).map_err(|error| {
1125 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1126 .at_parser_location(error.line(), error.column());
1127 envelope(error)
1128 })?;
1129
1130 validate_request_shape(&value)?;
1131
1132 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1133 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1134 })?;
1135
1136 validate_request_ranges(&request)?;
1137
1138 Ok(request)
1139}
1140
1141mod limits {
1154 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1158 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1160 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1162 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1164
1165 }
1172
1173fn require_range(
1174 value: f64,
1175 (min, max): (f64, f64),
1176 path: &str,
1177) -> Result<(), SolveErrorEnvelopeV1> {
1178 if !value.is_finite() {
1179 return Err(protocol_error(
1180 SolveErrorCodeV1::InvalidValue,
1181 format!("{path} must be a finite number"),
1182 path,
1183 ));
1184 }
1185 if value < min || value > max {
1186 return Err(protocol_error(
1187 SolveErrorCodeV1::InvalidValue,
1188 format!("{path} must be between {min} and {max}, got {value}"),
1189 path,
1190 ));
1191 }
1192 Ok(())
1193}
1194
1195fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1200 require_range(
1201 request.projectile.mass_kg,
1202 limits::MASS_KG,
1203 "$.projectile.mass_kg",
1204 )?;
1205 require_range(
1206 request.projectile.diameter_m,
1207 limits::DIAMETER_M,
1208 "$.projectile.diameter_m",
1209 )?;
1210 require_range(
1211 request.projectile.ballistic_coefficient,
1212 limits::BALLISTIC_COEFFICIENT,
1213 "$.projectile.ballistic_coefficient",
1214 )?;
1215 if let Some(length_m) = request.projectile.length_m {
1216 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1217 }
1218 Ok(())
1219}
1220
1221fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1222 SolveErrorEnvelopeV1::new(error)
1223}
1224
1225fn protocol_error(
1226 code: SolveErrorCodeV1,
1227 message: impl Into<String>,
1228 path: impl Into<String>,
1229) -> SolveErrorEnvelopeV1 {
1230 envelope(SolveErrorV1::new(code, message).at_path(path))
1231}
1232
1233fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1234 let root = require_object(value, "$")?;
1235
1236 validate_schema_version(root)?;
1240 validate_members(
1241 root,
1242 "$",
1243 &[
1244 "schema_version",
1245 "projectile",
1246 "rifle",
1247 "shot",
1248 "atmosphere",
1249 "wind",
1250 "solver",
1251 "effects",
1252 "sampling",
1253 "reticle",
1255 ],
1256 &[
1257 "schema_version",
1258 "projectile",
1259 "rifle",
1260 "shot",
1261 "atmosphere",
1262 "wind",
1263 "solver",
1264 "effects",
1265 "sampling",
1266 ],
1267 )?;
1268
1269 validate_projectile(required_value(root, "projectile", "$")?)?;
1270 validate_rifle(required_value(root, "rifle", "$")?)?;
1271 validate_shot(required_value(root, "shot", "$")?)?;
1272 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1273 validate_wind(required_value(root, "wind", "$")?)?;
1274 validate_solver(required_value(root, "solver", "$")?)?;
1275 validate_effects(required_value(root, "effects", "$")?)?;
1276 validate_sampling(required_value(root, "sampling", "$")?)?;
1277 if let Some(reticle) = root.get("reticle") {
1278 validate_reticle(reticle)?;
1279 }
1280 Ok(())
1281}
1282
1283fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1290 let path = "$.reticle";
1291 let object = require_object(value, path)?;
1292 validate_members(
1293 object,
1294 path,
1295 &["range_m", "magnification", "description"],
1296 &["range_m", "magnification", "description"],
1297 )?;
1298 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1299 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1300 Ok(())
1301}
1302
1303fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1304 let value = required_value(root, "schema_version", "$")?;
1305 let version = if let Some(version) = value.as_i64() {
1306 i128::from(version)
1307 } else if let Some(version) = value.as_u64() {
1308 i128::from(version)
1309 } else {
1310 return Err(protocol_error(
1311 SolveErrorCodeV1::InvalidValue,
1312 "schema_version must be the integer 1",
1313 "$.schema_version",
1314 ));
1315 };
1316 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1317 return Err(protocol_error(
1318 SolveErrorCodeV1::UnsupportedSchemaVersion,
1319 format!(
1320 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1321 ),
1322 "$.schema_version",
1323 ));
1324 }
1325 Ok(())
1326}
1327
1328fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1329 let path = "$.projectile";
1330 let object = require_object(value, path)?;
1331 validate_members(
1332 object,
1333 path,
1334 &[
1335 "mass_kg",
1336 "diameter_m",
1337 "length_m",
1338 "drag_model",
1339 "ballistic_coefficient",
1340 ],
1341 &[
1342 "mass_kg",
1343 "diameter_m",
1344 "drag_model",
1345 "ballistic_coefficient",
1346 ],
1347 )?;
1348 validate_required_numbers(
1349 object,
1350 path,
1351 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1352 )?;
1353 validate_optional_number(object, path, "length_m")?;
1354 validate_string_enum(
1355 required_value(object, "drag_model", path)?,
1356 "$.projectile.drag_model",
1357 &DRAG_MODEL_WIRE_NAMES_V1,
1358 )
1359}
1360
1361fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1362 let path = "$.rifle";
1363 let object = require_object(value, path)?;
1364 validate_members(
1365 object,
1366 path,
1367 &[
1368 "muzzle_velocity_mps",
1369 "sight_height_m",
1370 "muzzle_height_m",
1371 "twist_rate_m_per_turn",
1372 "twist_direction",
1373 "sight_offset_lateral_m",
1374 ],
1375 &["muzzle_velocity_mps"],
1376 )?;
1377 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1378 validate_optional_numbers(
1379 object,
1380 path,
1381 &[
1382 "sight_height_m",
1383 "muzzle_height_m",
1384 "twist_rate_m_per_turn",
1385 "sight_offset_lateral_m",
1386 ],
1387 )?;
1388 if let Some(direction) = object.get("twist_direction") {
1389 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1390 }
1391 Ok(())
1392}
1393
1394fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1395 let path = "$.shot";
1396 let object = require_object(value, path)?;
1397 validate_members(
1398 object,
1399 path,
1400 &[
1401 "max_range_m",
1402 "zero_distance_m",
1403 "muzzle_angle_rad",
1404 "aim_azimuth_rad",
1405 "shot_azimuth_rad",
1406 "shooting_angle_rad",
1407 "cant_angle_rad",
1408 "target_height_m",
1409 "ground_threshold_m",
1410 "zero_poi_up_m",
1411 "zero_poi_right_m",
1412 "drops_reference",
1413 ],
1414 &["max_range_m"],
1415 )?;
1416 validate_required_numbers(object, path, &["max_range_m"])?;
1417 validate_optional_number(object, path, "zero_distance_m")?;
1418 validate_optional_number(object, path, "muzzle_angle_rad")?;
1419 validate_optional_numbers(
1420 object,
1421 path,
1422 &[
1423 "aim_azimuth_rad",
1424 "shot_azimuth_rad",
1425 "shooting_angle_rad",
1426 "cant_angle_rad",
1427 "target_height_m",
1428 "ground_threshold_m",
1429 "zero_poi_up_m",
1430 "zero_poi_right_m",
1431 ],
1432 )?;
1433 if let Some(reference) = object.get("drops_reference") {
1436 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1437 }
1438 Ok(())
1439}
1440
1441fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1442 let path = "$.atmosphere";
1443 let object = require_object(value, path)?;
1444 validate_members(
1445 object,
1446 path,
1447 &[
1448 "altitude_m",
1449 "temperature_k",
1450 "pressure_pa",
1451 "pressure_reference",
1452 "relative_humidity",
1453 "latitude_rad",
1454 ],
1455 &[],
1456 )?;
1457 validate_optional_numbers(
1458 object,
1459 path,
1460 &[
1461 "altitude_m",
1462 "temperature_k",
1463 "pressure_pa",
1464 "relative_humidity",
1465 "latitude_rad",
1466 ],
1467 )?;
1468 if let Some(reference) = object.get("pressure_reference") {
1474 validate_string_enum(
1475 reference,
1476 "$.atmosphere.pressure_reference",
1477 &["absolute", "qnh"],
1478 )?;
1479 }
1480 Ok(())
1481}
1482
1483fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1484 let path = "$.wind";
1485 let object = require_object(value, path)?;
1486 validate_members(
1487 object,
1488 path,
1489 &[
1490 "speed_mps",
1491 "direction_from_rad",
1492 "vertical_speed_mps",
1493 "segments",
1494 "wind_reference",
1495 ],
1496 &[],
1497 )?;
1498 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1499 validate_optional_number(object, path, field)?;
1500 }
1501 if let Some(reference) = object.get("wind_reference") {
1503 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1504 }
1505
1506 if let Some(segments) = object.get("segments") {
1507 let Some(segments) = segments.as_array() else {
1508 return Err(protocol_error(
1509 SolveErrorCodeV1::InvalidValue,
1510 "segments must be an array",
1511 "$.wind.segments",
1512 ));
1513 };
1514 for (index, segment) in segments.iter().enumerate() {
1515 let segment_path = format!("$.wind.segments[{index}]");
1516 let segment = require_object(segment, &segment_path)?;
1517 validate_members(
1518 segment,
1519 &segment_path,
1520 &[
1521 "until_distance_m",
1522 "speed_mps",
1523 "direction_from_rad",
1524 "vertical_speed_mps",
1525 ],
1526 &["until_distance_m", "speed_mps", "direction_from_rad"],
1527 )?;
1528 validate_required_numbers(
1529 segment,
1530 &segment_path,
1531 &["until_distance_m", "speed_mps", "direction_from_rad"],
1532 )?;
1533 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1534 }
1535 }
1536 Ok(())
1537}
1538
1539fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1540 let path = "$.solver";
1541 let object = require_object(value, path)?;
1542 validate_members(object, path, &["method", "time_step_s"], &[])?;
1543 validate_optional_number(object, path, "time_step_s")?;
1544 if let Some(method) = object.get("method") {
1545 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1546 }
1547 Ok(())
1548}
1549
1550fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1551 let path = "$.effects";
1552 let object = require_object(value, path)?;
1553 validate_members(
1554 object,
1555 path,
1556 &["magnus", "coriolis", "enhanced_spin_drift"],
1557 &[],
1558 )?;
1559 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1560
1561 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1562 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1563 {
1564 return Err(protocol_error(
1565 SolveErrorCodeV1::ConflictingFields,
1566 "magnus and enhanced_spin_drift cannot both be enabled",
1567 "$.effects",
1568 ));
1569 }
1570
1571 Ok(())
1572}
1573
1574fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1575 let path = "$.sampling";
1576 let object = require_object(value, path)?;
1577 validate_members(object, path, &["interval_m"], &[])?;
1578 validate_optional_number(object, path, "interval_m")
1579}
1580
1581fn require_object<'a>(
1582 value: &'a Value,
1583 path: &str,
1584) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1585 value
1586 .as_object()
1587 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1588}
1589
1590fn required_value<'a>(
1591 object: &'a Map<String, Value>,
1592 field: &str,
1593 parent_path: &str,
1594) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1595 object.get(field).ok_or_else(|| {
1596 protocol_error(
1597 SolveErrorCodeV1::MissingField,
1598 format!("missing required field `{field}`"),
1599 child_path(parent_path, field),
1600 )
1601 })
1602}
1603
1604fn validate_members(
1605 object: &Map<String, Value>,
1606 path: &str,
1607 allowed: &[&str],
1608 required: &[&str],
1609) -> Result<(), SolveErrorEnvelopeV1> {
1610 if let Some(field) = object
1611 .keys()
1612 .find(|field| !allowed.contains(&field.as_str()))
1613 {
1614 return Err(protocol_error(
1615 SolveErrorCodeV1::UnknownField,
1616 format!("unknown field `{field}`"),
1617 child_path(path, field),
1618 ));
1619 }
1620
1621 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1622 return Err(protocol_error(
1623 SolveErrorCodeV1::MissingField,
1624 format!("missing required field `{field}`"),
1625 child_path(path, field),
1626 ));
1627 }
1628 Ok(())
1629}
1630
1631fn validate_string_enum(
1632 value: &Value,
1633 path: &str,
1634 allowed: &[&str],
1635) -> Result<(), SolveErrorEnvelopeV1> {
1636 let Some(value) = value.as_str() else {
1637 return Err(protocol_error(
1638 SolveErrorCodeV1::InvalidValue,
1639 "expected a string enum value",
1640 path,
1641 ));
1642 };
1643 if !allowed.contains(&value) {
1644 return Err(protocol_error(
1645 SolveErrorCodeV1::InvalidValue,
1646 format!(
1647 "invalid value `{value}`; expected one of {}",
1648 allowed.join(", ")
1649 ),
1650 path,
1651 ));
1652 }
1653 Ok(())
1654}
1655
1656fn validate_required_numbers(
1657 object: &Map<String, Value>,
1658 parent_path: &str,
1659 fields: &[&str],
1660) -> Result<(), SolveErrorEnvelopeV1> {
1661 for field in fields {
1662 let value = required_value(object, field, parent_path)?;
1663 validate_number(value, &child_path(parent_path, field))?;
1664 }
1665 Ok(())
1666}
1667
1668fn validate_optional_numbers(
1669 object: &Map<String, Value>,
1670 parent_path: &str,
1671 fields: &[&str],
1672) -> Result<(), SolveErrorEnvelopeV1> {
1673 for field in fields {
1674 validate_optional_number(object, parent_path, field)?;
1675 }
1676 Ok(())
1677}
1678
1679fn validate_optional_number(
1680 object: &Map<String, Value>,
1681 parent_path: &str,
1682 field: &str,
1683) -> Result<(), SolveErrorEnvelopeV1> {
1684 if let Some(value) = object.get(field) {
1685 validate_number(value, &child_path(parent_path, field))?;
1686 }
1687 Ok(())
1688}
1689
1690fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1691 if value.is_number() {
1692 Ok(())
1693 } else {
1694 Err(protocol_error(
1695 SolveErrorCodeV1::InvalidValue,
1696 "expected a number",
1697 path,
1698 ))
1699 }
1700}
1701
1702fn validate_optional_booleans(
1703 object: &Map<String, Value>,
1704 parent_path: &str,
1705 fields: &[&str],
1706) -> Result<(), SolveErrorEnvelopeV1> {
1707 for field in fields {
1708 if let Some(value) = object.get(*field) {
1709 if !value.is_boolean() {
1710 return Err(protocol_error(
1711 SolveErrorCodeV1::InvalidValue,
1712 "expected a boolean",
1713 child_path(parent_path, field),
1714 ));
1715 }
1716 }
1717 }
1718 Ok(())
1719}
1720
1721fn child_path(parent: &str, field: &str) -> String {
1722 format!("{parent}.{field}")
1723}
1724
1725#[cfg(test)]
1727mod request_range_tests {
1728 use super::*;
1729
1730 fn valid_request_json() -> String {
1733 r#"{"schema_version":1,
1734 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1735 "drag_model":"G7","ballistic_coefficient":0.243},
1736 "rifle":{"muzzle_velocity_mps":823.0},
1737 "shot":{"max_range_m":1000.0},
1738 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1739 .to_string()
1740 }
1741
1742 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1743 decode_solve_request_v1(json).expect_err("request should have been rejected")
1744 }
1745
1746 #[test]
1747 fn an_ordinary_request_still_decodes() {
1748 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1749 }
1750
1751 #[test]
1757 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1758 let reproducer = r#"{"schema_version":1,
1759 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1760 "drag_model":"G7","ballistic_coefficient":1.2e2},
1761 "rifle":{"muzzle_velocity_mps":823.0},
1762 "shot":{"max_range_m":100.0},
1763 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1764
1765 let envelope = decode_err(reproducer);
1766 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1767 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1768 }
1769
1770 #[test]
1773 fn the_rejection_envelope_round_trips() {
1774 let envelope = decode_err(
1775 r#"{"schema_version":1,
1776 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1777 "drag_model":"G7","ballistic_coefficient":0.243},
1778 "rifle":{"muzzle_velocity_mps":823.0},
1779 "shot":{"max_range_m":100.0},
1780 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1781 );
1782 let encoded = serde_json::to_string(&envelope).expect("serialize");
1783 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1784 assert_eq!(decoded, envelope);
1785 }
1786
1787 #[test]
1788 fn each_bounded_field_reports_its_own_path() {
1789 for (field, bad_value, path) in [
1790 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1791 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1792 (
1793 "ballistic_coefficient",
1794 "1.0e6",
1795 "$.projectile.ballistic_coefficient",
1796 ),
1797 ("length_m", "1.0e-9", "$.projectile.length_m"),
1798 ] {
1799 let json = valid_request_json().replace(
1800 &format!("\"{field}\":{}", default_for(field)),
1801 &format!("\"{field}\":{bad_value}"),
1802 );
1803 let envelope = decode_err(&json);
1804 assert_eq!(
1805 envelope.error.path(),
1806 Some(path),
1807 "wrong path for {field}"
1808 );
1809 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1810 }
1811 }
1812
1813 fn default_for(field: &str) -> &'static str {
1814 match field {
1815 "mass_kg" => "0.01134",
1816 "diameter_m" => "0.00782",
1817 "ballistic_coefficient" => "0.243",
1818 "length_m" => "0.031",
1819 other => panic!("no default recorded for {other}"),
1820 }
1821 }
1822
1823 #[test]
1827 fn muzzle_velocity_is_deliberately_left_unbounded() {
1828 let json = valid_request_json().replace("823.0", "1.0e308");
1829 decode_solve_request_v1(&json)
1830 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1831 }
1832}