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 MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
23
24fn deserialize_present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
30where
31 D: Deserializer<'de>,
32 T: Deserialize<'de>,
33{
34 T::deserialize(deserializer).map(Some)
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
42pub struct SchemaVersionV1;
43
44impl SchemaVersionV1 {
45 pub const fn get(self) -> u32 {
47 SOLVE_JSON_SCHEMA_VERSION_V1
48 }
49}
50
51impl Serialize for SchemaVersionV1 {
52 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53 where
54 S: Serializer,
55 {
56 serializer.serialize_u32(SOLVE_JSON_SCHEMA_VERSION_V1)
57 }
58}
59
60impl<'de> Deserialize<'de> for SchemaVersionV1 {
61 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62 where
63 D: Deserializer<'de>,
64 {
65 struct SchemaVersionVisitor;
66
67 impl<'de> Visitor<'de> for SchemaVersionVisitor {
68 type Value = SchemaVersionV1;
69
70 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71 formatter.write_str("the integer 1")
72 }
73
74 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
75 where
76 E: de::Error,
77 {
78 if value == u64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
79 Ok(SchemaVersionV1)
80 } else {
81 Err(E::custom(format!(
82 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
83 )))
84 }
85 }
86
87 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
88 where
89 E: de::Error,
90 {
91 if value == i64::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
92 Ok(SchemaVersionV1)
93 } else {
94 Err(E::custom(format!(
95 "unsupported schema_version {value}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
96 )))
97 }
98 }
99 }
100
101 deserializer.deserialize_any(SchemaVersionVisitor)
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct SolveRequestV1 {
112 pub schema_version: SchemaVersionV1,
114 pub projectile: ProjectileV1,
115 pub rifle: RifleV1,
116 pub shot: ShotV1,
117 pub atmosphere: AtmosphereV1,
118 pub wind: WindV1,
119 pub solver: SolverV1,
120 pub effects: EffectsV1,
121 pub sampling: SamplingV1,
122 #[serde(
129 default,
130 skip_serializing_if = "Option::is_none",
131 deserialize_with = "deserialize_present"
132 )]
133 pub reticle: Option<ReticleRequestV1>,
134}
135
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct ReticleRequestV1 {
146 pub range_m: f64,
148 pub magnification: f64,
151 pub description: crate::reticle::ReticleDescription,
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(deny_unknown_fields)]
160pub struct ReticleHoldV1 {
161 pub range_m: f64,
163 pub magnification: f64,
165 pub down_mil: f64,
166 pub right_mil: f64,
167 pub mark_scale: f64,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub nearest_mark_index: Option<usize>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub nearest_mark_label: Option<String>,
177 pub nearest_mark_distance_mil: f64,
178 pub off_reticle: bool,
181}
182
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct ProjectileV1 {
187 pub mass_kg: f64,
188 pub diameter_m: f64,
189 #[serde(
190 default,
191 skip_serializing_if = "Option::is_none",
192 deserialize_with = "deserialize_present"
193 )]
194 pub length_m: Option<f64>,
195 pub drag_model: DragModelV1,
196 pub ballistic_coefficient: f64,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201pub enum DragModelV1 {
202 #[serde(rename = "G1")]
203 G1,
204 #[serde(rename = "G6")]
205 G6,
206 #[serde(rename = "G7")]
207 G7,
208 #[serde(rename = "G8")]
209 G8,
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct RifleV1 {
216 pub muzzle_velocity_mps: f64,
217 #[serde(
218 default,
219 skip_serializing_if = "Option::is_none",
220 deserialize_with = "deserialize_present"
221 )]
222 pub sight_height_m: Option<f64>,
223 #[serde(
224 default,
225 skip_serializing_if = "Option::is_none",
226 deserialize_with = "deserialize_present"
227 )]
228 pub muzzle_height_m: Option<f64>,
229 #[serde(
230 default,
231 skip_serializing_if = "Option::is_none",
232 deserialize_with = "deserialize_present"
233 )]
234 pub twist_rate_m_per_turn: Option<f64>,
235 #[serde(
236 default,
237 skip_serializing_if = "Option::is_none",
238 deserialize_with = "deserialize_present"
239 )]
240 pub twist_direction: Option<TwistDirectionV1>,
241 #[serde(
249 default,
250 skip_serializing_if = "Option::is_none",
251 deserialize_with = "deserialize_present"
252 )]
253 pub sight_offset_lateral_m: Option<f64>,
254}
255
256#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(rename_all = "snake_case")]
259pub enum TwistDirectionV1 {
260 Left,
261 #[default]
262 Right,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct ShotV1 {
269 pub max_range_m: f64,
270 #[serde(
271 default,
272 skip_serializing_if = "Option::is_none",
273 deserialize_with = "deserialize_present"
274 )]
275 pub zero_distance_m: Option<f64>,
276 #[serde(
277 default,
278 skip_serializing_if = "Option::is_none",
279 deserialize_with = "deserialize_present"
280 )]
281 pub muzzle_angle_rad: Option<f64>,
282 #[serde(
283 default,
284 skip_serializing_if = "Option::is_none",
285 deserialize_with = "deserialize_present"
286 )]
287 pub aim_azimuth_rad: Option<f64>,
288 #[serde(
289 default,
290 skip_serializing_if = "Option::is_none",
291 deserialize_with = "deserialize_present"
292 )]
293 pub shot_azimuth_rad: Option<f64>,
294 #[serde(
295 default,
296 skip_serializing_if = "Option::is_none",
297 deserialize_with = "deserialize_present"
298 )]
299 pub shooting_angle_rad: Option<f64>,
300 #[serde(
301 default,
302 skip_serializing_if = "Option::is_none",
303 deserialize_with = "deserialize_present"
304 )]
305 pub cant_angle_rad: Option<f64>,
306 #[serde(
307 default,
308 skip_serializing_if = "Option::is_none",
309 deserialize_with = "deserialize_present"
310 )]
311 pub target_height_m: Option<f64>,
312 #[serde(
313 default,
314 skip_serializing_if = "Option::is_none",
315 deserialize_with = "deserialize_present"
316 )]
317 pub ground_threshold_m: Option<f64>,
318 #[serde(
324 default,
325 skip_serializing_if = "Option::is_none",
326 deserialize_with = "deserialize_present"
327 )]
328 pub zero_poi_up_m: Option<f64>,
329 #[serde(
333 default,
334 skip_serializing_if = "Option::is_none",
335 deserialize_with = "deserialize_present"
336 )]
337 pub zero_poi_right_m: Option<f64>,
338 #[serde(
346 default,
347 skip_serializing_if = "Option::is_none",
348 deserialize_with = "deserialize_present"
349 )]
350 pub drops_reference: Option<DropsReferenceV1>,
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum DropsReferenceV1 {
357 #[default]
359 Los,
360 Target,
363}
364
365#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct AtmosphereV1 {
369 #[serde(
370 default,
371 skip_serializing_if = "Option::is_none",
372 deserialize_with = "deserialize_present"
373 )]
374 pub altitude_m: Option<f64>,
375 #[serde(
378 default,
379 skip_serializing_if = "Option::is_none",
380 deserialize_with = "deserialize_present"
381 )]
382 pub temperature_k: Option<f64>,
383 #[serde(
389 default,
390 skip_serializing_if = "Option::is_none",
391 deserialize_with = "deserialize_present"
392 )]
393 pub pressure_pa: Option<f64>,
394 #[serde(
400 default,
401 skip_serializing_if = "Option::is_none",
402 deserialize_with = "deserialize_present"
403 )]
404 pub pressure_reference: Option<PressureReferenceV1>,
405 #[serde(
406 default,
407 skip_serializing_if = "Option::is_none",
408 deserialize_with = "deserialize_present"
409 )]
410 pub relative_humidity: Option<f64>,
411 #[serde(
412 default,
413 skip_serializing_if = "Option::is_none",
414 deserialize_with = "deserialize_present"
415 )]
416 pub latitude_rad: Option<f64>,
417}
418
419#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum PressureReferenceV1 {
425 #[default]
426 Absolute,
427 Qnh,
428}
429
430#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
435#[serde(deny_unknown_fields)]
436pub struct WindV1 {
437 #[serde(
438 default,
439 skip_serializing_if = "Option::is_none",
440 deserialize_with = "deserialize_present"
441 )]
442 pub speed_mps: Option<f64>,
443 #[serde(
444 default,
445 skip_serializing_if = "Option::is_none",
446 deserialize_with = "deserialize_present"
447 )]
448 pub direction_from_rad: Option<f64>,
449 #[serde(
450 default,
451 skip_serializing_if = "Option::is_none",
452 deserialize_with = "deserialize_present"
453 )]
454 pub vertical_speed_mps: Option<f64>,
455 #[serde(
456 default,
457 skip_serializing_if = "Option::is_none",
458 deserialize_with = "deserialize_present"
459 )]
460 pub segments: Option<Vec<WindSegmentV1>>,
461 #[serde(
474 default,
475 skip_serializing_if = "Option::is_none",
476 deserialize_with = "deserialize_present"
477 )]
478 pub wind_reference: Option<WindReferenceV1>,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
483#[serde(rename_all = "snake_case")]
484pub enum WindReferenceV1 {
485 #[default]
487 Shooter,
488 Compass,
490}
491
492#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
494#[serde(deny_unknown_fields)]
495pub struct WindSegmentV1 {
496 pub until_distance_m: f64,
497 pub speed_mps: f64,
498 pub direction_from_rad: f64,
499 #[serde(
500 default,
501 skip_serializing_if = "Option::is_none",
502 deserialize_with = "deserialize_present"
503 )]
504 pub vertical_speed_mps: Option<f64>,
505}
506
507#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
509#[serde(deny_unknown_fields)]
510pub struct SolverV1 {
511 #[serde(
512 default,
513 skip_serializing_if = "Option::is_none",
514 deserialize_with = "deserialize_present"
515 )]
516 pub method: Option<SolverMethodV1>,
517 #[serde(
519 default,
520 skip_serializing_if = "Option::is_none",
521 deserialize_with = "deserialize_present"
522 )]
523 pub time_step_s: Option<f64>,
524}
525
526#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
528#[serde(rename_all = "snake_case")]
529pub enum SolverMethodV1 {
530 Euler,
531 Rk4,
532 #[default]
533 Rk45,
534}
535
536#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct EffectsV1 {
540 #[serde(
541 default,
542 skip_serializing_if = "Option::is_none",
543 deserialize_with = "deserialize_present"
544 )]
545 pub magnus: Option<bool>,
546 #[serde(
547 default,
548 skip_serializing_if = "Option::is_none",
549 deserialize_with = "deserialize_present"
550 )]
551 pub coriolis: Option<bool>,
552 #[serde(
553 default,
554 skip_serializing_if = "Option::is_none",
555 deserialize_with = "deserialize_present"
556 )]
557 pub enhanced_spin_drift: Option<bool>,
558}
559
560#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct SamplingV1 {
564 #[serde(
565 default,
566 skip_serializing_if = "Option::is_none",
567 deserialize_with = "deserialize_present"
568 )]
569 pub interval_m: Option<f64>,
570}
571
572#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
578#[serde(deny_unknown_fields)]
579pub struct ResolvedSolveRequestV1 {
580 pub schema_version: SchemaVersionV1,
581 pub projectile: ResolvedProjectileV1,
582 pub rifle: ResolvedRifleV1,
583 pub shot: ResolvedShotV1,
584 pub atmosphere: ResolvedAtmosphereV1,
585 pub wind: ResolvedWindV1,
586 pub solver: ResolvedSolverV1,
587 pub effects: ResolvedEffectsV1,
588 pub sampling: ResolvedSamplingV1,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub reticle: Option<ReticleRequestV1>,
594}
595
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct ResolvedProjectileV1 {
600 pub mass_kg: f64,
601 pub diameter_m: f64,
602 #[serde(default, skip_serializing_if = "Option::is_none")]
603 pub length_m: Option<f64>,
604 pub drag_model: DragModelV1,
605 pub ballistic_coefficient: f64,
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
610#[serde(deny_unknown_fields)]
611pub struct ResolvedRifleV1 {
612 pub muzzle_velocity_mps: f64,
613 pub sight_height_m: f64,
614 pub muzzle_height_m: f64,
615 pub twist_rate_m_per_turn: f64,
616 pub twist_direction: TwistDirectionV1,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub sight_offset_lateral_m: Option<f64>,
621}
622
623#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct ResolvedShotV1 {
637 pub max_range_m: f64,
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub zero_distance_m: Option<f64>,
640 pub muzzle_angle_rad: f64,
641 pub aim_azimuth_rad: f64,
642 pub shot_azimuth_rad: f64,
643 pub shooting_angle_rad: f64,
644 pub cant_angle_rad: f64,
645 pub target_height_m: f64,
646 pub ground_threshold_m: f64,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub zero_poi_up_m: Option<f64>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub zero_poi_right_m: Option<f64>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub drops_reference: Option<DropsReferenceV1>,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct ResolvedAtmosphereV1 {
665 pub altitude_m: f64,
666 pub temperature_k: f64,
667 pub pressure_pa: f64,
668 pub relative_humidity: f64,
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub latitude_rad: Option<f64>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub pressure_reference: Option<PressureReferenceV1>,
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
682#[serde(untagged)]
683pub enum ResolvedWindV1 {
684 Constant(ResolvedConstantWindV1),
685 Segmented(ResolvedSegmentedWindV1),
686}
687
688#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690#[serde(deny_unknown_fields)]
691pub struct ResolvedConstantWindV1 {
692 pub speed_mps: f64,
693 pub direction_from_rad: f64,
694 pub vertical_speed_mps: f64,
695 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub wind_reference: Option<WindReferenceV1>,
700}
701
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(deny_unknown_fields)]
705pub struct ResolvedSegmentedWindV1 {
706 pub segments: Vec<ResolvedWindSegmentV1>,
707 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub wind_reference: Option<WindReferenceV1>,
712}
713
714#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
716#[serde(deny_unknown_fields)]
717pub struct ResolvedWindSegmentV1 {
718 pub until_distance_m: f64,
719 pub speed_mps: f64,
720 pub direction_from_rad: f64,
721 pub vertical_speed_mps: f64,
722}
723
724#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
726#[serde(deny_unknown_fields)]
727pub struct ResolvedSolverV1 {
728 pub method: SolverMethodV1,
729 pub time_step_s: f64,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734#[serde(deny_unknown_fields)]
735pub struct ResolvedEffectsV1 {
736 pub magnus: bool,
737 pub coriolis: bool,
738 pub enhanced_spin_drift: bool,
739}
740
741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(deny_unknown_fields)]
744pub struct ResolvedSamplingV1 {
745 pub interval_m: f64,
746}
747
748#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct SolveSuccessV1 {
752 pub schema_version: SchemaVersionV1,
753 pub engine_version: String,
754 pub status: SuccessStatusV1,
755 pub resolved_request: ResolvedSolveRequestV1,
756 #[serde(default)]
757 pub assumptions: Vec<SolveNoticeV1>,
758 #[serde(default)]
759 pub warnings: Vec<SolveNoticeV1>,
760 pub summary: SolveSummaryV1,
761 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
762 pub samples: Vec<TrajectorySampleV1>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub reticle_hold: Option<ReticleHoldV1>,
768}
769
770fn serialize_solve_samples_v1<S>(
771 samples: &[TrajectorySampleV1],
772 serializer: S,
773) -> Result<S::Ok, S::Error>
774where
775 S: Serializer,
776{
777 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
778 return Err(serde::ser::Error::custom(format_args!(
779 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
780 samples.len()
781 )));
782 }
783 samples.serialize(serializer)
784}
785
786impl SolveSuccessV1 {
787 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
793 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
794 return Ok(());
795 }
796
797 Err(SolveErrorEnvelopeV1::new(
798 SolveErrorV1::new(
799 SolveErrorCodeV1::ResourceLimit,
800 format!(
801 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
802 self.samples.len()
803 ),
804 )
805 .at_path("$.sampling.interval_m"),
806 ))
807 }
808}
809
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
812#[serde(rename_all = "snake_case")]
813pub enum SuccessStatusV1 {
814 Ok,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(deny_unknown_fields)]
820pub struct SolveNoticeV1 {
821 pub code: String,
822 pub message: String,
823 #[serde(default, skip_serializing_if = "Option::is_none")]
824 pub path: Option<String>,
825}
826
827#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
829#[serde(deny_unknown_fields)]
830pub struct SolveSummaryV1 {
831 pub actual_range_m: f64,
832 pub maximum_height_m: f64,
835 pub time_of_flight_s: f64,
836 pub terminal_speed_mps: f64,
837 pub terminal_energy_j: f64,
838 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub stability_factor: Option<f64>,
842 #[serde(default, skip_serializing_if = "Option::is_none")]
846 pub spin_drift_m: Option<f64>,
847 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub equivalent_horizontal_range_m: Option<f64>,
856 pub termination: TerminationReasonV1,
857}
858
859#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
861#[serde(rename_all = "snake_case")]
862pub enum TerminationReasonV1 {
863 MaxRange,
864 GroundThreshold,
865 TimeLimit,
866 VelocityFloor,
867}
868
869#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
871#[serde(deny_unknown_fields)]
872pub struct TrajectorySampleV1 {
873 pub distance_m: f64,
874 pub time_s: f64,
875 pub speed_mps: f64,
876 pub energy_j: f64,
877 pub drop_m: f64,
879 pub windage_m: f64,
881 pub mach: f64,
882 #[serde(default)]
883 pub flags: Vec<SampleFlagV1>,
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum SampleFlagV1 {
890 Transonic,
891 Subsonic,
892 Terminal,
893 GroundThreshold,
894}
895
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898#[serde(deny_unknown_fields)]
899pub struct SolveErrorEnvelopeV1 {
900 pub schema_version: SchemaVersionV1,
901 pub status: ErrorStatusV1,
902 pub error: SolveErrorV1,
903}
904
905impl SolveErrorEnvelopeV1 {
906 pub fn new(error: SolveErrorV1) -> Self {
908 Self {
909 schema_version: SchemaVersionV1,
910 status: ErrorStatusV1::Error,
911 error,
912 }
913 }
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
918#[serde(rename_all = "snake_case")]
919pub enum ErrorStatusV1 {
920 Error,
921}
922
923#[derive(Debug, Clone, PartialEq, Eq)]
929pub struct SolveErrorV1 {
930 pub code: SolveErrorCodeV1,
931 pub message: String,
932 location: SolveErrorLocationV1,
933}
934
935#[derive(Debug, Clone, PartialEq, Eq)]
936enum SolveErrorLocationV1 {
937 None,
938 Path(String),
939 Source {
940 line: NonZeroUsize,
941 column: NonZeroUsize,
942 },
943}
944
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
947pub enum SolveErrorLocationErrorV1 {
948 ZeroLine,
949 ZeroColumn,
950}
951
952impl fmt::Display for SolveErrorLocationErrorV1 {
953 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
954 match self {
955 Self::ZeroLine => formatter.write_str("error line must be one-based"),
956 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
957 }
958 }
959}
960
961impl std::error::Error for SolveErrorLocationErrorV1 {}
962
963impl SolveErrorV1 {
964 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
966 Self {
967 code,
968 message: message.into(),
969 location: SolveErrorLocationV1::None,
970 }
971 }
972
973 pub fn at_path(mut self, path: impl Into<String>) -> Self {
975 self.location = SolveErrorLocationV1::Path(path.into());
976 self
977 }
978
979 pub fn at_location(
981 mut self,
982 line: usize,
983 column: usize,
984 ) -> Result<Self, SolveErrorLocationErrorV1> {
985 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
986 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
987 self.location = SolveErrorLocationV1::Source { line, column };
988 Ok(self)
989 }
990
991 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
996 self.location = SolveErrorLocationV1::Source {
997 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
998 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
999 };
1000 self
1001 }
1002
1003 pub fn path(&self) -> Option<&str> {
1005 match &self.location {
1006 SolveErrorLocationV1::Path(path) => Some(path),
1007 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
1008 }
1009 }
1010
1011 pub fn line(&self) -> Option<usize> {
1013 match &self.location {
1014 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
1015 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1016 }
1017 }
1018
1019 pub fn column(&self) -> Option<usize> {
1021 match &self.location {
1022 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
1023 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
1024 }
1025 }
1026}
1027
1028impl Serialize for SolveErrorV1 {
1029 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1030 where
1031 S: Serializer,
1032 {
1033 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
1034 state.serialize_field("code", &self.code)?;
1035 state.serialize_field("message", &self.message)?;
1036 state.serialize_field("path", &self.path())?;
1037 state.serialize_field("line", &self.line())?;
1038 state.serialize_field("column", &self.column())?;
1039 state.end()
1040 }
1041}
1042
1043impl<'de> Deserialize<'de> for SolveErrorV1 {
1044 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1045 where
1046 D: Deserializer<'de>,
1047 {
1048 #[derive(Deserialize)]
1049 #[serde(deny_unknown_fields)]
1050 struct SolveErrorWireV1 {
1051 code: SolveErrorCodeV1,
1052 message: String,
1053 path: Option<String>,
1054 line: Option<usize>,
1055 column: Option<usize>,
1056 }
1057
1058 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1059 let location = match (wire.path, wire.line, wire.column) {
1060 (None, None, None) => SolveErrorLocationV1::None,
1061 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1062 (None, Some(line), Some(column)) => {
1063 let line = NonZeroUsize::new(line)
1064 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1065 let column = NonZeroUsize::new(column)
1066 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1067 SolveErrorLocationV1::Source { line, column }
1068 }
1069 _ => {
1070 return Err(de::Error::custom(
1071 "error location must contain either path or both line and column",
1072 ));
1073 }
1074 };
1075
1076 Ok(Self {
1077 code: wire.code,
1078 message: wire.message,
1079 location,
1080 })
1081 }
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1086#[serde(rename_all = "snake_case")]
1087pub enum SolveErrorCodeV1 {
1088 InvalidJson,
1089 UnsupportedSchemaVersion,
1090 UnknownField,
1091 MissingField,
1092 InvalidValue,
1093 ConflictingFields,
1094 ResourceLimit,
1095 SolveFailed,
1096 IoError,
1097 InternalError,
1098}
1099
1100pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1107 let value: Value = serde_json::from_str(input).map_err(|error| {
1108 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1109 .at_parser_location(error.line(), error.column());
1110 envelope(error)
1111 })?;
1112
1113 validate_request_shape(&value)?;
1114
1115 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1116 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1117 })?;
1118
1119 validate_request_ranges(&request)?;
1120
1121 Ok(request)
1122}
1123
1124mod limits {
1137 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1141 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1143 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1145 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1147
1148 }
1155
1156fn require_range(
1157 value: f64,
1158 (min, max): (f64, f64),
1159 path: &str,
1160) -> Result<(), SolveErrorEnvelopeV1> {
1161 if !value.is_finite() {
1162 return Err(protocol_error(
1163 SolveErrorCodeV1::InvalidValue,
1164 format!("{path} must be a finite number"),
1165 path,
1166 ));
1167 }
1168 if value < min || value > max {
1169 return Err(protocol_error(
1170 SolveErrorCodeV1::InvalidValue,
1171 format!("{path} must be between {min} and {max}, got {value}"),
1172 path,
1173 ));
1174 }
1175 Ok(())
1176}
1177
1178fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1183 require_range(
1184 request.projectile.mass_kg,
1185 limits::MASS_KG,
1186 "$.projectile.mass_kg",
1187 )?;
1188 require_range(
1189 request.projectile.diameter_m,
1190 limits::DIAMETER_M,
1191 "$.projectile.diameter_m",
1192 )?;
1193 require_range(
1194 request.projectile.ballistic_coefficient,
1195 limits::BALLISTIC_COEFFICIENT,
1196 "$.projectile.ballistic_coefficient",
1197 )?;
1198 if let Some(length_m) = request.projectile.length_m {
1199 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1200 }
1201 Ok(())
1202}
1203
1204fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1205 SolveErrorEnvelopeV1::new(error)
1206}
1207
1208fn protocol_error(
1209 code: SolveErrorCodeV1,
1210 message: impl Into<String>,
1211 path: impl Into<String>,
1212) -> SolveErrorEnvelopeV1 {
1213 envelope(SolveErrorV1::new(code, message).at_path(path))
1214}
1215
1216fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1217 let root = require_object(value, "$")?;
1218
1219 validate_schema_version(root)?;
1223 validate_members(
1224 root,
1225 "$",
1226 &[
1227 "schema_version",
1228 "projectile",
1229 "rifle",
1230 "shot",
1231 "atmosphere",
1232 "wind",
1233 "solver",
1234 "effects",
1235 "sampling",
1236 "reticle",
1238 ],
1239 &[
1240 "schema_version",
1241 "projectile",
1242 "rifle",
1243 "shot",
1244 "atmosphere",
1245 "wind",
1246 "solver",
1247 "effects",
1248 "sampling",
1249 ],
1250 )?;
1251
1252 validate_projectile(required_value(root, "projectile", "$")?)?;
1253 validate_rifle(required_value(root, "rifle", "$")?)?;
1254 validate_shot(required_value(root, "shot", "$")?)?;
1255 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1256 validate_wind(required_value(root, "wind", "$")?)?;
1257 validate_solver(required_value(root, "solver", "$")?)?;
1258 validate_effects(required_value(root, "effects", "$")?)?;
1259 validate_sampling(required_value(root, "sampling", "$")?)?;
1260 if let Some(reticle) = root.get("reticle") {
1261 validate_reticle(reticle)?;
1262 }
1263 Ok(())
1264}
1265
1266fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1273 let path = "$.reticle";
1274 let object = require_object(value, path)?;
1275 validate_members(
1276 object,
1277 path,
1278 &["range_m", "magnification", "description"],
1279 &["range_m", "magnification", "description"],
1280 )?;
1281 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1282 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1283 Ok(())
1284}
1285
1286fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1287 let value = required_value(root, "schema_version", "$")?;
1288 let version = if let Some(version) = value.as_i64() {
1289 i128::from(version)
1290 } else if let Some(version) = value.as_u64() {
1291 i128::from(version)
1292 } else {
1293 return Err(protocol_error(
1294 SolveErrorCodeV1::InvalidValue,
1295 "schema_version must be the integer 1",
1296 "$.schema_version",
1297 ));
1298 };
1299 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1300 return Err(protocol_error(
1301 SolveErrorCodeV1::UnsupportedSchemaVersion,
1302 format!(
1303 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1304 ),
1305 "$.schema_version",
1306 ));
1307 }
1308 Ok(())
1309}
1310
1311fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1312 let path = "$.projectile";
1313 let object = require_object(value, path)?;
1314 validate_members(
1315 object,
1316 path,
1317 &[
1318 "mass_kg",
1319 "diameter_m",
1320 "length_m",
1321 "drag_model",
1322 "ballistic_coefficient",
1323 ],
1324 &[
1325 "mass_kg",
1326 "diameter_m",
1327 "drag_model",
1328 "ballistic_coefficient",
1329 ],
1330 )?;
1331 validate_required_numbers(
1332 object,
1333 path,
1334 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1335 )?;
1336 validate_optional_number(object, path, "length_m")?;
1337 validate_string_enum(
1338 required_value(object, "drag_model", path)?,
1339 "$.projectile.drag_model",
1340 &["G1", "G6", "G7", "G8"],
1341 )
1342}
1343
1344fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1345 let path = "$.rifle";
1346 let object = require_object(value, path)?;
1347 validate_members(
1348 object,
1349 path,
1350 &[
1351 "muzzle_velocity_mps",
1352 "sight_height_m",
1353 "muzzle_height_m",
1354 "twist_rate_m_per_turn",
1355 "twist_direction",
1356 "sight_offset_lateral_m",
1357 ],
1358 &["muzzle_velocity_mps"],
1359 )?;
1360 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1361 validate_optional_numbers(
1362 object,
1363 path,
1364 &[
1365 "sight_height_m",
1366 "muzzle_height_m",
1367 "twist_rate_m_per_turn",
1368 "sight_offset_lateral_m",
1369 ],
1370 )?;
1371 if let Some(direction) = object.get("twist_direction") {
1372 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1373 }
1374 Ok(())
1375}
1376
1377fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1378 let path = "$.shot";
1379 let object = require_object(value, path)?;
1380 validate_members(
1381 object,
1382 path,
1383 &[
1384 "max_range_m",
1385 "zero_distance_m",
1386 "muzzle_angle_rad",
1387 "aim_azimuth_rad",
1388 "shot_azimuth_rad",
1389 "shooting_angle_rad",
1390 "cant_angle_rad",
1391 "target_height_m",
1392 "ground_threshold_m",
1393 "zero_poi_up_m",
1394 "zero_poi_right_m",
1395 "drops_reference",
1396 ],
1397 &["max_range_m"],
1398 )?;
1399 validate_required_numbers(object, path, &["max_range_m"])?;
1400 validate_optional_number(object, path, "zero_distance_m")?;
1401 validate_optional_number(object, path, "muzzle_angle_rad")?;
1402 validate_optional_numbers(
1403 object,
1404 path,
1405 &[
1406 "aim_azimuth_rad",
1407 "shot_azimuth_rad",
1408 "shooting_angle_rad",
1409 "cant_angle_rad",
1410 "target_height_m",
1411 "ground_threshold_m",
1412 "zero_poi_up_m",
1413 "zero_poi_right_m",
1414 ],
1415 )?;
1416 if let Some(reference) = object.get("drops_reference") {
1419 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1420 }
1421 Ok(())
1422}
1423
1424fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1425 let path = "$.atmosphere";
1426 let object = require_object(value, path)?;
1427 validate_members(
1428 object,
1429 path,
1430 &[
1431 "altitude_m",
1432 "temperature_k",
1433 "pressure_pa",
1434 "pressure_reference",
1435 "relative_humidity",
1436 "latitude_rad",
1437 ],
1438 &[],
1439 )?;
1440 validate_optional_numbers(
1441 object,
1442 path,
1443 &[
1444 "altitude_m",
1445 "temperature_k",
1446 "pressure_pa",
1447 "relative_humidity",
1448 "latitude_rad",
1449 ],
1450 )?;
1451 if let Some(reference) = object.get("pressure_reference") {
1457 validate_string_enum(
1458 reference,
1459 "$.atmosphere.pressure_reference",
1460 &["absolute", "qnh"],
1461 )?;
1462 }
1463 Ok(())
1464}
1465
1466fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1467 let path = "$.wind";
1468 let object = require_object(value, path)?;
1469 validate_members(
1470 object,
1471 path,
1472 &[
1473 "speed_mps",
1474 "direction_from_rad",
1475 "vertical_speed_mps",
1476 "segments",
1477 "wind_reference",
1478 ],
1479 &[],
1480 )?;
1481 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1482 validate_optional_number(object, path, field)?;
1483 }
1484 if let Some(reference) = object.get("wind_reference") {
1486 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1487 }
1488
1489 if let Some(segments) = object.get("segments") {
1490 let Some(segments) = segments.as_array() else {
1491 return Err(protocol_error(
1492 SolveErrorCodeV1::InvalidValue,
1493 "segments must be an array",
1494 "$.wind.segments",
1495 ));
1496 };
1497 for (index, segment) in segments.iter().enumerate() {
1498 let segment_path = format!("$.wind.segments[{index}]");
1499 let segment = require_object(segment, &segment_path)?;
1500 validate_members(
1501 segment,
1502 &segment_path,
1503 &[
1504 "until_distance_m",
1505 "speed_mps",
1506 "direction_from_rad",
1507 "vertical_speed_mps",
1508 ],
1509 &["until_distance_m", "speed_mps", "direction_from_rad"],
1510 )?;
1511 validate_required_numbers(
1512 segment,
1513 &segment_path,
1514 &["until_distance_m", "speed_mps", "direction_from_rad"],
1515 )?;
1516 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1517 }
1518 }
1519 Ok(())
1520}
1521
1522fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1523 let path = "$.solver";
1524 let object = require_object(value, path)?;
1525 validate_members(object, path, &["method", "time_step_s"], &[])?;
1526 validate_optional_number(object, path, "time_step_s")?;
1527 if let Some(method) = object.get("method") {
1528 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1529 }
1530 Ok(())
1531}
1532
1533fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1534 let path = "$.effects";
1535 let object = require_object(value, path)?;
1536 validate_members(
1537 object,
1538 path,
1539 &["magnus", "coriolis", "enhanced_spin_drift"],
1540 &[],
1541 )?;
1542 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1543
1544 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1545 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1546 {
1547 return Err(protocol_error(
1548 SolveErrorCodeV1::ConflictingFields,
1549 "magnus and enhanced_spin_drift cannot both be enabled",
1550 "$.effects",
1551 ));
1552 }
1553
1554 Ok(())
1555}
1556
1557fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1558 let path = "$.sampling";
1559 let object = require_object(value, path)?;
1560 validate_members(object, path, &["interval_m"], &[])?;
1561 validate_optional_number(object, path, "interval_m")
1562}
1563
1564fn require_object<'a>(
1565 value: &'a Value,
1566 path: &str,
1567) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1568 value
1569 .as_object()
1570 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1571}
1572
1573fn required_value<'a>(
1574 object: &'a Map<String, Value>,
1575 field: &str,
1576 parent_path: &str,
1577) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1578 object.get(field).ok_or_else(|| {
1579 protocol_error(
1580 SolveErrorCodeV1::MissingField,
1581 format!("missing required field `{field}`"),
1582 child_path(parent_path, field),
1583 )
1584 })
1585}
1586
1587fn validate_members(
1588 object: &Map<String, Value>,
1589 path: &str,
1590 allowed: &[&str],
1591 required: &[&str],
1592) -> Result<(), SolveErrorEnvelopeV1> {
1593 if let Some(field) = object
1594 .keys()
1595 .find(|field| !allowed.contains(&field.as_str()))
1596 {
1597 return Err(protocol_error(
1598 SolveErrorCodeV1::UnknownField,
1599 format!("unknown field `{field}`"),
1600 child_path(path, field),
1601 ));
1602 }
1603
1604 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1605 return Err(protocol_error(
1606 SolveErrorCodeV1::MissingField,
1607 format!("missing required field `{field}`"),
1608 child_path(path, field),
1609 ));
1610 }
1611 Ok(())
1612}
1613
1614fn validate_string_enum(
1615 value: &Value,
1616 path: &str,
1617 allowed: &[&str],
1618) -> Result<(), SolveErrorEnvelopeV1> {
1619 let Some(value) = value.as_str() else {
1620 return Err(protocol_error(
1621 SolveErrorCodeV1::InvalidValue,
1622 "expected a string enum value",
1623 path,
1624 ));
1625 };
1626 if !allowed.contains(&value) {
1627 return Err(protocol_error(
1628 SolveErrorCodeV1::InvalidValue,
1629 format!(
1630 "invalid value `{value}`; expected one of {}",
1631 allowed.join(", ")
1632 ),
1633 path,
1634 ));
1635 }
1636 Ok(())
1637}
1638
1639fn validate_required_numbers(
1640 object: &Map<String, Value>,
1641 parent_path: &str,
1642 fields: &[&str],
1643) -> Result<(), SolveErrorEnvelopeV1> {
1644 for field in fields {
1645 let value = required_value(object, field, parent_path)?;
1646 validate_number(value, &child_path(parent_path, field))?;
1647 }
1648 Ok(())
1649}
1650
1651fn validate_optional_numbers(
1652 object: &Map<String, Value>,
1653 parent_path: &str,
1654 fields: &[&str],
1655) -> Result<(), SolveErrorEnvelopeV1> {
1656 for field in fields {
1657 validate_optional_number(object, parent_path, field)?;
1658 }
1659 Ok(())
1660}
1661
1662fn validate_optional_number(
1663 object: &Map<String, Value>,
1664 parent_path: &str,
1665 field: &str,
1666) -> Result<(), SolveErrorEnvelopeV1> {
1667 if let Some(value) = object.get(field) {
1668 validate_number(value, &child_path(parent_path, field))?;
1669 }
1670 Ok(())
1671}
1672
1673fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1674 if value.is_number() {
1675 Ok(())
1676 } else {
1677 Err(protocol_error(
1678 SolveErrorCodeV1::InvalidValue,
1679 "expected a number",
1680 path,
1681 ))
1682 }
1683}
1684
1685fn validate_optional_booleans(
1686 object: &Map<String, Value>,
1687 parent_path: &str,
1688 fields: &[&str],
1689) -> Result<(), SolveErrorEnvelopeV1> {
1690 for field in fields {
1691 if let Some(value) = object.get(*field) {
1692 if !value.is_boolean() {
1693 return Err(protocol_error(
1694 SolveErrorCodeV1::InvalidValue,
1695 "expected a boolean",
1696 child_path(parent_path, field),
1697 ));
1698 }
1699 }
1700 }
1701 Ok(())
1702}
1703
1704fn child_path(parent: &str, field: &str) -> String {
1705 format!("{parent}.{field}")
1706}
1707
1708#[cfg(test)]
1710mod request_range_tests {
1711 use super::*;
1712
1713 fn valid_request_json() -> String {
1716 r#"{"schema_version":1,
1717 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1718 "drag_model":"G7","ballistic_coefficient":0.243},
1719 "rifle":{"muzzle_velocity_mps":823.0},
1720 "shot":{"max_range_m":1000.0},
1721 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1722 .to_string()
1723 }
1724
1725 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1726 decode_solve_request_v1(json).expect_err("request should have been rejected")
1727 }
1728
1729 #[test]
1730 fn an_ordinary_request_still_decodes() {
1731 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1732 }
1733
1734 #[test]
1740 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1741 let reproducer = r#"{"schema_version":1,
1742 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1743 "drag_model":"G7","ballistic_coefficient":1.2e2},
1744 "rifle":{"muzzle_velocity_mps":823.0},
1745 "shot":{"max_range_m":100.0},
1746 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1747
1748 let envelope = decode_err(reproducer);
1749 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1750 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1751 }
1752
1753 #[test]
1756 fn the_rejection_envelope_round_trips() {
1757 let envelope = decode_err(
1758 r#"{"schema_version":1,
1759 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1760 "drag_model":"G7","ballistic_coefficient":0.243},
1761 "rifle":{"muzzle_velocity_mps":823.0},
1762 "shot":{"max_range_m":100.0},
1763 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1764 );
1765 let encoded = serde_json::to_string(&envelope).expect("serialize");
1766 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1767 assert_eq!(decoded, envelope);
1768 }
1769
1770 #[test]
1771 fn each_bounded_field_reports_its_own_path() {
1772 for (field, bad_value, path) in [
1773 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1774 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1775 (
1776 "ballistic_coefficient",
1777 "1.0e6",
1778 "$.projectile.ballistic_coefficient",
1779 ),
1780 ("length_m", "1.0e-9", "$.projectile.length_m"),
1781 ] {
1782 let json = valid_request_json().replace(
1783 &format!("\"{field}\":{}", default_for(field)),
1784 &format!("\"{field}\":{bad_value}"),
1785 );
1786 let envelope = decode_err(&json);
1787 assert_eq!(
1788 envelope.error.path(),
1789 Some(path),
1790 "wrong path for {field}"
1791 );
1792 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1793 }
1794 }
1795
1796 fn default_for(field: &str) -> &'static str {
1797 match field {
1798 "mass_kg" => "0.01134",
1799 "diameter_m" => "0.00782",
1800 "ballistic_coefficient" => "0.243",
1801 "length_m" => "0.031",
1802 other => panic!("no default recorded for {other}"),
1803 }
1804 }
1805
1806 #[test]
1810 fn muzzle_velocity_is_deliberately_left_unbounded() {
1811 let json = valid_request_json().replace("823.0", "1.0e308");
1812 decode_solve_request_v1(&json)
1813 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1814 }
1815}