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(
127 default,
128 skip_serializing_if = "Option::is_none",
129 deserialize_with = "deserialize_present"
130 )]
131 pub reticle: Option<ReticleRequestV1>,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub struct ReticleRequestV1 {
144 pub range_m: f64,
146 pub magnification: f64,
149 pub description: crate::reticle::ReticleDescription,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct ReticleHoldV1 {
159 pub range_m: f64,
161 pub magnification: f64,
163 pub down_mil: f64,
164 pub right_mil: f64,
165 pub mark_scale: f64,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub nearest_mark_index: Option<usize>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub nearest_mark_label: Option<String>,
175 pub nearest_mark_distance_mil: f64,
176 pub off_reticle: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183#[serde(deny_unknown_fields)]
184pub struct ProjectileV1 {
185 pub mass_kg: f64,
186 pub diameter_m: f64,
187 #[serde(
188 default,
189 skip_serializing_if = "Option::is_none",
190 deserialize_with = "deserialize_present"
191 )]
192 pub length_m: Option<f64>,
193 pub drag_model: DragModelV1,
194 pub ballistic_coefficient: f64,
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
199pub enum DragModelV1 {
200 #[serde(rename = "G1")]
201 G1,
202 #[serde(rename = "G6")]
203 G6,
204 #[serde(rename = "G7")]
205 G7,
206 #[serde(rename = "G8")]
207 G8,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields)]
213pub struct RifleV1 {
214 pub muzzle_velocity_mps: f64,
215 #[serde(
216 default,
217 skip_serializing_if = "Option::is_none",
218 deserialize_with = "deserialize_present"
219 )]
220 pub sight_height_m: Option<f64>,
221 #[serde(
222 default,
223 skip_serializing_if = "Option::is_none",
224 deserialize_with = "deserialize_present"
225 )]
226 pub muzzle_height_m: Option<f64>,
227 #[serde(
228 default,
229 skip_serializing_if = "Option::is_none",
230 deserialize_with = "deserialize_present"
231 )]
232 pub twist_rate_m_per_turn: Option<f64>,
233 #[serde(
234 default,
235 skip_serializing_if = "Option::is_none",
236 deserialize_with = "deserialize_present"
237 )]
238 pub twist_direction: Option<TwistDirectionV1>,
239 #[serde(
247 default,
248 skip_serializing_if = "Option::is_none",
249 deserialize_with = "deserialize_present"
250 )]
251 pub sight_offset_lateral_m: Option<f64>,
252}
253
254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum TwistDirectionV1 {
258 Left,
259 #[default]
260 Right,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct ShotV1 {
267 pub max_range_m: f64,
268 #[serde(
269 default,
270 skip_serializing_if = "Option::is_none",
271 deserialize_with = "deserialize_present"
272 )]
273 pub zero_distance_m: Option<f64>,
274 #[serde(
275 default,
276 skip_serializing_if = "Option::is_none",
277 deserialize_with = "deserialize_present"
278 )]
279 pub muzzle_angle_rad: Option<f64>,
280 #[serde(
281 default,
282 skip_serializing_if = "Option::is_none",
283 deserialize_with = "deserialize_present"
284 )]
285 pub aim_azimuth_rad: Option<f64>,
286 #[serde(
287 default,
288 skip_serializing_if = "Option::is_none",
289 deserialize_with = "deserialize_present"
290 )]
291 pub shot_azimuth_rad: Option<f64>,
292 #[serde(
293 default,
294 skip_serializing_if = "Option::is_none",
295 deserialize_with = "deserialize_present"
296 )]
297 pub shooting_angle_rad: Option<f64>,
298 #[serde(
299 default,
300 skip_serializing_if = "Option::is_none",
301 deserialize_with = "deserialize_present"
302 )]
303 pub cant_angle_rad: Option<f64>,
304 #[serde(
305 default,
306 skip_serializing_if = "Option::is_none",
307 deserialize_with = "deserialize_present"
308 )]
309 pub target_height_m: Option<f64>,
310 #[serde(
311 default,
312 skip_serializing_if = "Option::is_none",
313 deserialize_with = "deserialize_present"
314 )]
315 pub ground_threshold_m: Option<f64>,
316 #[serde(
322 default,
323 skip_serializing_if = "Option::is_none",
324 deserialize_with = "deserialize_present"
325 )]
326 pub zero_poi_up_m: Option<f64>,
327 #[serde(
331 default,
332 skip_serializing_if = "Option::is_none",
333 deserialize_with = "deserialize_present"
334 )]
335 pub zero_poi_right_m: Option<f64>,
336 #[serde(
344 default,
345 skip_serializing_if = "Option::is_none",
346 deserialize_with = "deserialize_present"
347 )]
348 pub drops_reference: Option<DropsReferenceV1>,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum DropsReferenceV1 {
355 #[default]
357 Los,
358 Target,
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct AtmosphereV1 {
367 #[serde(
368 default,
369 skip_serializing_if = "Option::is_none",
370 deserialize_with = "deserialize_present"
371 )]
372 pub altitude_m: Option<f64>,
373 #[serde(
376 default,
377 skip_serializing_if = "Option::is_none",
378 deserialize_with = "deserialize_present"
379 )]
380 pub temperature_k: Option<f64>,
381 #[serde(
387 default,
388 skip_serializing_if = "Option::is_none",
389 deserialize_with = "deserialize_present"
390 )]
391 pub pressure_pa: Option<f64>,
392 #[serde(
398 default,
399 skip_serializing_if = "Option::is_none",
400 deserialize_with = "deserialize_present"
401 )]
402 pub pressure_reference: Option<PressureReferenceV1>,
403 #[serde(
404 default,
405 skip_serializing_if = "Option::is_none",
406 deserialize_with = "deserialize_present"
407 )]
408 pub relative_humidity: Option<f64>,
409 #[serde(
410 default,
411 skip_serializing_if = "Option::is_none",
412 deserialize_with = "deserialize_present"
413 )]
414 pub latitude_rad: Option<f64>,
415}
416
417#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum PressureReferenceV1 {
423 #[default]
424 Absolute,
425 Qnh,
426}
427
428#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct WindV1 {
435 #[serde(
436 default,
437 skip_serializing_if = "Option::is_none",
438 deserialize_with = "deserialize_present"
439 )]
440 pub speed_mps: Option<f64>,
441 #[serde(
442 default,
443 skip_serializing_if = "Option::is_none",
444 deserialize_with = "deserialize_present"
445 )]
446 pub direction_from_rad: Option<f64>,
447 #[serde(
448 default,
449 skip_serializing_if = "Option::is_none",
450 deserialize_with = "deserialize_present"
451 )]
452 pub vertical_speed_mps: Option<f64>,
453 #[serde(
454 default,
455 skip_serializing_if = "Option::is_none",
456 deserialize_with = "deserialize_present"
457 )]
458 pub segments: Option<Vec<WindSegmentV1>>,
459 #[serde(
470 default,
471 skip_serializing_if = "Option::is_none",
472 deserialize_with = "deserialize_present"
473 )]
474 pub wind_reference: Option<WindReferenceV1>,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum WindReferenceV1 {
481 #[default]
483 Shooter,
484 Compass,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct WindSegmentV1 {
492 pub until_distance_m: f64,
493 pub speed_mps: f64,
494 pub direction_from_rad: f64,
495 #[serde(
496 default,
497 skip_serializing_if = "Option::is_none",
498 deserialize_with = "deserialize_present"
499 )]
500 pub vertical_speed_mps: Option<f64>,
501}
502
503#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
505#[serde(deny_unknown_fields)]
506pub struct SolverV1 {
507 #[serde(
508 default,
509 skip_serializing_if = "Option::is_none",
510 deserialize_with = "deserialize_present"
511 )]
512 pub method: Option<SolverMethodV1>,
513 #[serde(
515 default,
516 skip_serializing_if = "Option::is_none",
517 deserialize_with = "deserialize_present"
518 )]
519 pub time_step_s: Option<f64>,
520}
521
522#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum SolverMethodV1 {
526 Euler,
527 Rk4,
528 #[default]
529 Rk45,
530}
531
532#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub struct EffectsV1 {
536 #[serde(
537 default,
538 skip_serializing_if = "Option::is_none",
539 deserialize_with = "deserialize_present"
540 )]
541 pub magnus: Option<bool>,
542 #[serde(
543 default,
544 skip_serializing_if = "Option::is_none",
545 deserialize_with = "deserialize_present"
546 )]
547 pub coriolis: Option<bool>,
548 #[serde(
549 default,
550 skip_serializing_if = "Option::is_none",
551 deserialize_with = "deserialize_present"
552 )]
553 pub enhanced_spin_drift: Option<bool>,
554}
555
556#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
558#[serde(deny_unknown_fields)]
559pub struct SamplingV1 {
560 #[serde(
561 default,
562 skip_serializing_if = "Option::is_none",
563 deserialize_with = "deserialize_present"
564 )]
565 pub interval_m: Option<f64>,
566}
567
568#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
574#[serde(deny_unknown_fields)]
575pub struct ResolvedSolveRequestV1 {
576 pub schema_version: SchemaVersionV1,
577 pub projectile: ResolvedProjectileV1,
578 pub rifle: ResolvedRifleV1,
579 pub shot: ResolvedShotV1,
580 pub atmosphere: ResolvedAtmosphereV1,
581 pub wind: ResolvedWindV1,
582 pub solver: ResolvedSolverV1,
583 pub effects: ResolvedEffectsV1,
584 pub sampling: ResolvedSamplingV1,
585}
586
587#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589#[serde(deny_unknown_fields)]
590pub struct ResolvedProjectileV1 {
591 pub mass_kg: f64,
592 pub diameter_m: f64,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub length_m: Option<f64>,
595 pub drag_model: DragModelV1,
596 pub ballistic_coefficient: f64,
597}
598
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct ResolvedRifleV1 {
603 pub muzzle_velocity_mps: f64,
604 pub sight_height_m: f64,
605 pub muzzle_height_m: f64,
606 pub twist_rate_m_per_turn: f64,
607 pub twist_direction: TwistDirectionV1,
608}
609
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct ResolvedShotV1 {
617 pub max_range_m: f64,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub zero_distance_m: Option<f64>,
620 pub muzzle_angle_rad: f64,
621 pub aim_azimuth_rad: f64,
622 pub shot_azimuth_rad: f64,
623 pub shooting_angle_rad: f64,
624 pub cant_angle_rad: f64,
625 pub target_height_m: f64,
626 pub ground_threshold_m: f64,
627}
628
629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
631#[serde(deny_unknown_fields)]
632pub struct ResolvedAtmosphereV1 {
633 pub altitude_m: f64,
634 pub temperature_k: f64,
635 pub pressure_pa: f64,
636 pub relative_humidity: f64,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub latitude_rad: Option<f64>,
639}
640
641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
646#[serde(untagged)]
647pub enum ResolvedWindV1 {
648 Constant(ResolvedConstantWindV1),
649 Segmented(ResolvedSegmentedWindV1),
650}
651
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
654#[serde(deny_unknown_fields)]
655pub struct ResolvedConstantWindV1 {
656 pub speed_mps: f64,
657 pub direction_from_rad: f64,
658 pub vertical_speed_mps: f64,
659}
660
661#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663#[serde(deny_unknown_fields)]
664pub struct ResolvedSegmentedWindV1 {
665 pub segments: Vec<ResolvedWindSegmentV1>,
666}
667
668#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
670#[serde(deny_unknown_fields)]
671pub struct ResolvedWindSegmentV1 {
672 pub until_distance_m: f64,
673 pub speed_mps: f64,
674 pub direction_from_rad: f64,
675 pub vertical_speed_mps: f64,
676}
677
678#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
680#[serde(deny_unknown_fields)]
681pub struct ResolvedSolverV1 {
682 pub method: SolverMethodV1,
683 pub time_step_s: f64,
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
688#[serde(deny_unknown_fields)]
689pub struct ResolvedEffectsV1 {
690 pub magnus: bool,
691 pub coriolis: bool,
692 pub enhanced_spin_drift: bool,
693}
694
695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
697#[serde(deny_unknown_fields)]
698pub struct ResolvedSamplingV1 {
699 pub interval_m: f64,
700}
701
702#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
704#[serde(deny_unknown_fields)]
705pub struct SolveSuccessV1 {
706 pub schema_version: SchemaVersionV1,
707 pub engine_version: String,
708 pub status: SuccessStatusV1,
709 pub resolved_request: ResolvedSolveRequestV1,
710 #[serde(default)]
711 pub assumptions: Vec<SolveNoticeV1>,
712 #[serde(default)]
713 pub warnings: Vec<SolveNoticeV1>,
714 pub summary: SolveSummaryV1,
715 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
716 pub samples: Vec<TrajectorySampleV1>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub reticle_hold: Option<ReticleHoldV1>,
722}
723
724fn serialize_solve_samples_v1<S>(
725 samples: &[TrajectorySampleV1],
726 serializer: S,
727) -> Result<S::Ok, S::Error>
728where
729 S: Serializer,
730{
731 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
732 return Err(serde::ser::Error::custom(format_args!(
733 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
734 samples.len()
735 )));
736 }
737 samples.serialize(serializer)
738}
739
740impl SolveSuccessV1 {
741 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
747 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
748 return Ok(());
749 }
750
751 Err(SolveErrorEnvelopeV1::new(
752 SolveErrorV1::new(
753 SolveErrorCodeV1::ResourceLimit,
754 format!(
755 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
756 self.samples.len()
757 ),
758 )
759 .at_path("$.sampling.interval_m"),
760 ))
761 }
762}
763
764#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
766#[serde(rename_all = "snake_case")]
767pub enum SuccessStatusV1 {
768 Ok,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct SolveNoticeV1 {
775 pub code: String,
776 pub message: String,
777 #[serde(default, skip_serializing_if = "Option::is_none")]
778 pub path: Option<String>,
779}
780
781#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub struct SolveSummaryV1 {
785 pub actual_range_m: f64,
786 pub maximum_height_m: f64,
789 pub time_of_flight_s: f64,
790 pub terminal_speed_mps: f64,
791 pub terminal_energy_j: f64,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub stability_factor: Option<f64>,
796 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub spin_drift_m: Option<f64>,
801 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub equivalent_horizontal_range_m: Option<f64>,
810 pub termination: TerminationReasonV1,
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum TerminationReasonV1 {
817 MaxRange,
818 GroundThreshold,
819 TimeLimit,
820 VelocityFloor,
821}
822
823#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
825#[serde(deny_unknown_fields)]
826pub struct TrajectorySampleV1 {
827 pub distance_m: f64,
828 pub time_s: f64,
829 pub speed_mps: f64,
830 pub energy_j: f64,
831 pub drop_m: f64,
833 pub windage_m: f64,
835 pub mach: f64,
836 #[serde(default)]
837 pub flags: Vec<SampleFlagV1>,
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "snake_case")]
843pub enum SampleFlagV1 {
844 Transonic,
845 Subsonic,
846 Terminal,
847 GroundThreshold,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
852#[serde(deny_unknown_fields)]
853pub struct SolveErrorEnvelopeV1 {
854 pub schema_version: SchemaVersionV1,
855 pub status: ErrorStatusV1,
856 pub error: SolveErrorV1,
857}
858
859impl SolveErrorEnvelopeV1 {
860 pub fn new(error: SolveErrorV1) -> Self {
862 Self {
863 schema_version: SchemaVersionV1,
864 status: ErrorStatusV1::Error,
865 error,
866 }
867 }
868}
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "snake_case")]
873pub enum ErrorStatusV1 {
874 Error,
875}
876
877#[derive(Debug, Clone, PartialEq, Eq)]
883pub struct SolveErrorV1 {
884 pub code: SolveErrorCodeV1,
885 pub message: String,
886 location: SolveErrorLocationV1,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
890enum SolveErrorLocationV1 {
891 None,
892 Path(String),
893 Source {
894 line: NonZeroUsize,
895 column: NonZeroUsize,
896 },
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq)]
901pub enum SolveErrorLocationErrorV1 {
902 ZeroLine,
903 ZeroColumn,
904}
905
906impl fmt::Display for SolveErrorLocationErrorV1 {
907 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
908 match self {
909 Self::ZeroLine => formatter.write_str("error line must be one-based"),
910 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
911 }
912 }
913}
914
915impl std::error::Error for SolveErrorLocationErrorV1 {}
916
917impl SolveErrorV1 {
918 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
920 Self {
921 code,
922 message: message.into(),
923 location: SolveErrorLocationV1::None,
924 }
925 }
926
927 pub fn at_path(mut self, path: impl Into<String>) -> Self {
929 self.location = SolveErrorLocationV1::Path(path.into());
930 self
931 }
932
933 pub fn at_location(
935 mut self,
936 line: usize,
937 column: usize,
938 ) -> Result<Self, SolveErrorLocationErrorV1> {
939 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
940 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
941 self.location = SolveErrorLocationV1::Source { line, column };
942 Ok(self)
943 }
944
945 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
950 self.location = SolveErrorLocationV1::Source {
951 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
952 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
953 };
954 self
955 }
956
957 pub fn path(&self) -> Option<&str> {
959 match &self.location {
960 SolveErrorLocationV1::Path(path) => Some(path),
961 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
962 }
963 }
964
965 pub fn line(&self) -> Option<usize> {
967 match &self.location {
968 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
969 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
970 }
971 }
972
973 pub fn column(&self) -> Option<usize> {
975 match &self.location {
976 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
977 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
978 }
979 }
980}
981
982impl Serialize for SolveErrorV1 {
983 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
984 where
985 S: Serializer,
986 {
987 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
988 state.serialize_field("code", &self.code)?;
989 state.serialize_field("message", &self.message)?;
990 state.serialize_field("path", &self.path())?;
991 state.serialize_field("line", &self.line())?;
992 state.serialize_field("column", &self.column())?;
993 state.end()
994 }
995}
996
997impl<'de> Deserialize<'de> for SolveErrorV1 {
998 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
999 where
1000 D: Deserializer<'de>,
1001 {
1002 #[derive(Deserialize)]
1003 #[serde(deny_unknown_fields)]
1004 struct SolveErrorWireV1 {
1005 code: SolveErrorCodeV1,
1006 message: String,
1007 path: Option<String>,
1008 line: Option<usize>,
1009 column: Option<usize>,
1010 }
1011
1012 let wire = SolveErrorWireV1::deserialize(deserializer)?;
1013 let location = match (wire.path, wire.line, wire.column) {
1014 (None, None, None) => SolveErrorLocationV1::None,
1015 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
1016 (None, Some(line), Some(column)) => {
1017 let line = NonZeroUsize::new(line)
1018 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
1019 let column = NonZeroUsize::new(column)
1020 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
1021 SolveErrorLocationV1::Source { line, column }
1022 }
1023 _ => {
1024 return Err(de::Error::custom(
1025 "error location must contain either path or both line and column",
1026 ));
1027 }
1028 };
1029
1030 Ok(Self {
1031 code: wire.code,
1032 message: wire.message,
1033 location,
1034 })
1035 }
1036}
1037
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(rename_all = "snake_case")]
1041pub enum SolveErrorCodeV1 {
1042 InvalidJson,
1043 UnsupportedSchemaVersion,
1044 UnknownField,
1045 MissingField,
1046 InvalidValue,
1047 ConflictingFields,
1048 ResourceLimit,
1049 SolveFailed,
1050 IoError,
1051 InternalError,
1052}
1053
1054pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
1061 let value: Value = serde_json::from_str(input).map_err(|error| {
1062 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
1063 .at_parser_location(error.line(), error.column());
1064 envelope(error)
1065 })?;
1066
1067 validate_request_shape(&value)?;
1068
1069 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
1070 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
1071 })?;
1072
1073 validate_request_ranges(&request)?;
1074
1075 Ok(request)
1076}
1077
1078mod limits {
1091 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
1095 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
1097 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
1099 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
1101
1102 }
1109
1110fn require_range(
1111 value: f64,
1112 (min, max): (f64, f64),
1113 path: &str,
1114) -> Result<(), SolveErrorEnvelopeV1> {
1115 if !value.is_finite() {
1116 return Err(protocol_error(
1117 SolveErrorCodeV1::InvalidValue,
1118 format!("{path} must be a finite number"),
1119 path,
1120 ));
1121 }
1122 if value < min || value > max {
1123 return Err(protocol_error(
1124 SolveErrorCodeV1::InvalidValue,
1125 format!("{path} must be between {min} and {max}, got {value}"),
1126 path,
1127 ));
1128 }
1129 Ok(())
1130}
1131
1132fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
1137 require_range(
1138 request.projectile.mass_kg,
1139 limits::MASS_KG,
1140 "$.projectile.mass_kg",
1141 )?;
1142 require_range(
1143 request.projectile.diameter_m,
1144 limits::DIAMETER_M,
1145 "$.projectile.diameter_m",
1146 )?;
1147 require_range(
1148 request.projectile.ballistic_coefficient,
1149 limits::BALLISTIC_COEFFICIENT,
1150 "$.projectile.ballistic_coefficient",
1151 )?;
1152 if let Some(length_m) = request.projectile.length_m {
1153 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
1154 }
1155 Ok(())
1156}
1157
1158fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1159 SolveErrorEnvelopeV1::new(error)
1160}
1161
1162fn protocol_error(
1163 code: SolveErrorCodeV1,
1164 message: impl Into<String>,
1165 path: impl Into<String>,
1166) -> SolveErrorEnvelopeV1 {
1167 envelope(SolveErrorV1::new(code, message).at_path(path))
1168}
1169
1170fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1171 let root = require_object(value, "$")?;
1172
1173 validate_schema_version(root)?;
1177 validate_members(
1178 root,
1179 "$",
1180 &[
1181 "schema_version",
1182 "projectile",
1183 "rifle",
1184 "shot",
1185 "atmosphere",
1186 "wind",
1187 "solver",
1188 "effects",
1189 "sampling",
1190 "reticle",
1192 ],
1193 &[
1194 "schema_version",
1195 "projectile",
1196 "rifle",
1197 "shot",
1198 "atmosphere",
1199 "wind",
1200 "solver",
1201 "effects",
1202 "sampling",
1203 ],
1204 )?;
1205
1206 validate_projectile(required_value(root, "projectile", "$")?)?;
1207 validate_rifle(required_value(root, "rifle", "$")?)?;
1208 validate_shot(required_value(root, "shot", "$")?)?;
1209 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1210 validate_wind(required_value(root, "wind", "$")?)?;
1211 validate_solver(required_value(root, "solver", "$")?)?;
1212 validate_effects(required_value(root, "effects", "$")?)?;
1213 validate_sampling(required_value(root, "sampling", "$")?)?;
1214 if let Some(reticle) = root.get("reticle") {
1215 validate_reticle(reticle)?;
1216 }
1217 Ok(())
1218}
1219
1220fn validate_reticle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1227 let path = "$.reticle";
1228 let object = require_object(value, path)?;
1229 validate_members(
1230 object,
1231 path,
1232 &["range_m", "magnification", "description"],
1233 &["range_m", "magnification", "description"],
1234 )?;
1235 validate_required_numbers(object, path, &["range_m", "magnification"])?;
1236 require_object(required_value(object, "description", path)?, "$.reticle.description")?;
1237 Ok(())
1238}
1239
1240fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1241 let value = required_value(root, "schema_version", "$")?;
1242 let version = if let Some(version) = value.as_i64() {
1243 i128::from(version)
1244 } else if let Some(version) = value.as_u64() {
1245 i128::from(version)
1246 } else {
1247 return Err(protocol_error(
1248 SolveErrorCodeV1::InvalidValue,
1249 "schema_version must be the integer 1",
1250 "$.schema_version",
1251 ));
1252 };
1253 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1254 return Err(protocol_error(
1255 SolveErrorCodeV1::UnsupportedSchemaVersion,
1256 format!(
1257 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1258 ),
1259 "$.schema_version",
1260 ));
1261 }
1262 Ok(())
1263}
1264
1265fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1266 let path = "$.projectile";
1267 let object = require_object(value, path)?;
1268 validate_members(
1269 object,
1270 path,
1271 &[
1272 "mass_kg",
1273 "diameter_m",
1274 "length_m",
1275 "drag_model",
1276 "ballistic_coefficient",
1277 ],
1278 &[
1279 "mass_kg",
1280 "diameter_m",
1281 "drag_model",
1282 "ballistic_coefficient",
1283 ],
1284 )?;
1285 validate_required_numbers(
1286 object,
1287 path,
1288 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1289 )?;
1290 validate_optional_number(object, path, "length_m")?;
1291 validate_string_enum(
1292 required_value(object, "drag_model", path)?,
1293 "$.projectile.drag_model",
1294 &["G1", "G6", "G7", "G8"],
1295 )
1296}
1297
1298fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1299 let path = "$.rifle";
1300 let object = require_object(value, path)?;
1301 validate_members(
1302 object,
1303 path,
1304 &[
1305 "muzzle_velocity_mps",
1306 "sight_height_m",
1307 "muzzle_height_m",
1308 "twist_rate_m_per_turn",
1309 "twist_direction",
1310 "sight_offset_lateral_m",
1311 ],
1312 &["muzzle_velocity_mps"],
1313 )?;
1314 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1315 validate_optional_numbers(
1316 object,
1317 path,
1318 &[
1319 "sight_height_m",
1320 "muzzle_height_m",
1321 "twist_rate_m_per_turn",
1322 "sight_offset_lateral_m",
1323 ],
1324 )?;
1325 if let Some(direction) = object.get("twist_direction") {
1326 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1327 }
1328 Ok(())
1329}
1330
1331fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1332 let path = "$.shot";
1333 let object = require_object(value, path)?;
1334 validate_members(
1335 object,
1336 path,
1337 &[
1338 "max_range_m",
1339 "zero_distance_m",
1340 "muzzle_angle_rad",
1341 "aim_azimuth_rad",
1342 "shot_azimuth_rad",
1343 "shooting_angle_rad",
1344 "cant_angle_rad",
1345 "target_height_m",
1346 "ground_threshold_m",
1347 "zero_poi_up_m",
1348 "zero_poi_right_m",
1349 "drops_reference",
1350 ],
1351 &["max_range_m"],
1352 )?;
1353 validate_required_numbers(object, path, &["max_range_m"])?;
1354 validate_optional_number(object, path, "zero_distance_m")?;
1355 validate_optional_number(object, path, "muzzle_angle_rad")?;
1356 validate_optional_numbers(
1357 object,
1358 path,
1359 &[
1360 "aim_azimuth_rad",
1361 "shot_azimuth_rad",
1362 "shooting_angle_rad",
1363 "cant_angle_rad",
1364 "target_height_m",
1365 "ground_threshold_m",
1366 "zero_poi_up_m",
1367 "zero_poi_right_m",
1368 ],
1369 )?;
1370 if let Some(reference) = object.get("drops_reference") {
1373 validate_string_enum(reference, "$.shot.drops_reference", &["los", "target"])?;
1374 }
1375 Ok(())
1376}
1377
1378fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1379 let path = "$.atmosphere";
1380 let object = require_object(value, path)?;
1381 validate_members(
1382 object,
1383 path,
1384 &[
1385 "altitude_m",
1386 "temperature_k",
1387 "pressure_pa",
1388 "relative_humidity",
1389 "latitude_rad",
1390 ],
1391 &[],
1392 )?;
1393 validate_optional_numbers(
1394 object,
1395 path,
1396 &[
1397 "altitude_m",
1398 "temperature_k",
1399 "pressure_pa",
1400 "relative_humidity",
1401 "latitude_rad",
1402 ],
1403 )
1404}
1405
1406fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1407 let path = "$.wind";
1408 let object = require_object(value, path)?;
1409 validate_members(
1410 object,
1411 path,
1412 &[
1413 "speed_mps",
1414 "direction_from_rad",
1415 "vertical_speed_mps",
1416 "segments",
1417 "wind_reference",
1418 ],
1419 &[],
1420 )?;
1421 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1422 validate_optional_number(object, path, field)?;
1423 }
1424 if let Some(reference) = object.get("wind_reference") {
1426 validate_string_enum(reference, "$.wind.wind_reference", &["shooter", "compass"])?;
1427 }
1428
1429 if let Some(segments) = object.get("segments") {
1430 let Some(segments) = segments.as_array() else {
1431 return Err(protocol_error(
1432 SolveErrorCodeV1::InvalidValue,
1433 "segments must be an array",
1434 "$.wind.segments",
1435 ));
1436 };
1437 for (index, segment) in segments.iter().enumerate() {
1438 let segment_path = format!("$.wind.segments[{index}]");
1439 let segment = require_object(segment, &segment_path)?;
1440 validate_members(
1441 segment,
1442 &segment_path,
1443 &[
1444 "until_distance_m",
1445 "speed_mps",
1446 "direction_from_rad",
1447 "vertical_speed_mps",
1448 ],
1449 &["until_distance_m", "speed_mps", "direction_from_rad"],
1450 )?;
1451 validate_required_numbers(
1452 segment,
1453 &segment_path,
1454 &["until_distance_m", "speed_mps", "direction_from_rad"],
1455 )?;
1456 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1457 }
1458 }
1459 Ok(())
1460}
1461
1462fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1463 let path = "$.solver";
1464 let object = require_object(value, path)?;
1465 validate_members(object, path, &["method", "time_step_s"], &[])?;
1466 validate_optional_number(object, path, "time_step_s")?;
1467 if let Some(method) = object.get("method") {
1468 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1469 }
1470 Ok(())
1471}
1472
1473fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1474 let path = "$.effects";
1475 let object = require_object(value, path)?;
1476 validate_members(
1477 object,
1478 path,
1479 &["magnus", "coriolis", "enhanced_spin_drift"],
1480 &[],
1481 )?;
1482 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1483
1484 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1485 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1486 {
1487 return Err(protocol_error(
1488 SolveErrorCodeV1::ConflictingFields,
1489 "magnus and enhanced_spin_drift cannot both be enabled",
1490 "$.effects",
1491 ));
1492 }
1493
1494 Ok(())
1495}
1496
1497fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1498 let path = "$.sampling";
1499 let object = require_object(value, path)?;
1500 validate_members(object, path, &["interval_m"], &[])?;
1501 validate_optional_number(object, path, "interval_m")
1502}
1503
1504fn require_object<'a>(
1505 value: &'a Value,
1506 path: &str,
1507) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1508 value
1509 .as_object()
1510 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1511}
1512
1513fn required_value<'a>(
1514 object: &'a Map<String, Value>,
1515 field: &str,
1516 parent_path: &str,
1517) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1518 object.get(field).ok_or_else(|| {
1519 protocol_error(
1520 SolveErrorCodeV1::MissingField,
1521 format!("missing required field `{field}`"),
1522 child_path(parent_path, field),
1523 )
1524 })
1525}
1526
1527fn validate_members(
1528 object: &Map<String, Value>,
1529 path: &str,
1530 allowed: &[&str],
1531 required: &[&str],
1532) -> Result<(), SolveErrorEnvelopeV1> {
1533 if let Some(field) = object
1534 .keys()
1535 .find(|field| !allowed.contains(&field.as_str()))
1536 {
1537 return Err(protocol_error(
1538 SolveErrorCodeV1::UnknownField,
1539 format!("unknown field `{field}`"),
1540 child_path(path, field),
1541 ));
1542 }
1543
1544 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1545 return Err(protocol_error(
1546 SolveErrorCodeV1::MissingField,
1547 format!("missing required field `{field}`"),
1548 child_path(path, field),
1549 ));
1550 }
1551 Ok(())
1552}
1553
1554fn validate_string_enum(
1555 value: &Value,
1556 path: &str,
1557 allowed: &[&str],
1558) -> Result<(), SolveErrorEnvelopeV1> {
1559 let Some(value) = value.as_str() else {
1560 return Err(protocol_error(
1561 SolveErrorCodeV1::InvalidValue,
1562 "expected a string enum value",
1563 path,
1564 ));
1565 };
1566 if !allowed.contains(&value) {
1567 return Err(protocol_error(
1568 SolveErrorCodeV1::InvalidValue,
1569 format!(
1570 "invalid value `{value}`; expected one of {}",
1571 allowed.join(", ")
1572 ),
1573 path,
1574 ));
1575 }
1576 Ok(())
1577}
1578
1579fn validate_required_numbers(
1580 object: &Map<String, Value>,
1581 parent_path: &str,
1582 fields: &[&str],
1583) -> Result<(), SolveErrorEnvelopeV1> {
1584 for field in fields {
1585 let value = required_value(object, field, parent_path)?;
1586 validate_number(value, &child_path(parent_path, field))?;
1587 }
1588 Ok(())
1589}
1590
1591fn validate_optional_numbers(
1592 object: &Map<String, Value>,
1593 parent_path: &str,
1594 fields: &[&str],
1595) -> Result<(), SolveErrorEnvelopeV1> {
1596 for field in fields {
1597 validate_optional_number(object, parent_path, field)?;
1598 }
1599 Ok(())
1600}
1601
1602fn validate_optional_number(
1603 object: &Map<String, Value>,
1604 parent_path: &str,
1605 field: &str,
1606) -> Result<(), SolveErrorEnvelopeV1> {
1607 if let Some(value) = object.get(field) {
1608 validate_number(value, &child_path(parent_path, field))?;
1609 }
1610 Ok(())
1611}
1612
1613fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1614 if value.is_number() {
1615 Ok(())
1616 } else {
1617 Err(protocol_error(
1618 SolveErrorCodeV1::InvalidValue,
1619 "expected a number",
1620 path,
1621 ))
1622 }
1623}
1624
1625fn validate_optional_booleans(
1626 object: &Map<String, Value>,
1627 parent_path: &str,
1628 fields: &[&str],
1629) -> Result<(), SolveErrorEnvelopeV1> {
1630 for field in fields {
1631 if let Some(value) = object.get(*field) {
1632 if !value.is_boolean() {
1633 return Err(protocol_error(
1634 SolveErrorCodeV1::InvalidValue,
1635 "expected a boolean",
1636 child_path(parent_path, field),
1637 ));
1638 }
1639 }
1640 }
1641 Ok(())
1642}
1643
1644fn child_path(parent: &str, field: &str) -> String {
1645 format!("{parent}.{field}")
1646}
1647
1648#[cfg(test)]
1650mod request_range_tests {
1651 use super::*;
1652
1653 fn valid_request_json() -> String {
1656 r#"{"schema_version":1,
1657 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1658 "drag_model":"G7","ballistic_coefficient":0.243},
1659 "rifle":{"muzzle_velocity_mps":823.0},
1660 "shot":{"max_range_m":1000.0},
1661 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1662 .to_string()
1663 }
1664
1665 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1666 decode_solve_request_v1(json).expect_err("request should have been rejected")
1667 }
1668
1669 #[test]
1670 fn an_ordinary_request_still_decodes() {
1671 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1672 }
1673
1674 #[test]
1680 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1681 let reproducer = r#"{"schema_version":1,
1682 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1683 "drag_model":"G7","ballistic_coefficient":1.2e2},
1684 "rifle":{"muzzle_velocity_mps":823.0},
1685 "shot":{"max_range_m":100.0},
1686 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1687
1688 let envelope = decode_err(reproducer);
1689 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1690 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1691 }
1692
1693 #[test]
1696 fn the_rejection_envelope_round_trips() {
1697 let envelope = decode_err(
1698 r#"{"schema_version":1,
1699 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1700 "drag_model":"G7","ballistic_coefficient":0.243},
1701 "rifle":{"muzzle_velocity_mps":823.0},
1702 "shot":{"max_range_m":100.0},
1703 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1704 );
1705 let encoded = serde_json::to_string(&envelope).expect("serialize");
1706 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1707 assert_eq!(decoded, envelope);
1708 }
1709
1710 #[test]
1711 fn each_bounded_field_reports_its_own_path() {
1712 for (field, bad_value, path) in [
1713 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1714 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1715 (
1716 "ballistic_coefficient",
1717 "1.0e6",
1718 "$.projectile.ballistic_coefficient",
1719 ),
1720 ("length_m", "1.0e-9", "$.projectile.length_m"),
1721 ] {
1722 let json = valid_request_json().replace(
1723 &format!("\"{field}\":{}", default_for(field)),
1724 &format!("\"{field}\":{bad_value}"),
1725 );
1726 let envelope = decode_err(&json);
1727 assert_eq!(
1728 envelope.error.path(),
1729 Some(path),
1730 "wrong path for {field}"
1731 );
1732 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1733 }
1734 }
1735
1736 fn default_for(field: &str) -> &'static str {
1737 match field {
1738 "mass_kg" => "0.01134",
1739 "diameter_m" => "0.00782",
1740 "ballistic_coefficient" => "0.243",
1741 "length_m" => "0.031",
1742 other => panic!("no default recorded for {other}"),
1743 }
1744 }
1745
1746 #[test]
1750 fn muzzle_velocity_is_deliberately_left_unbounded() {
1751 let json = valid_request_json().replace("823.0", "1.0e308");
1752 decode_solve_request_v1(&json)
1753 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1754 }
1755}