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}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct ProjectileV1 {
128 pub mass_kg: f64,
129 pub diameter_m: f64,
130 #[serde(
131 default,
132 skip_serializing_if = "Option::is_none",
133 deserialize_with = "deserialize_present"
134 )]
135 pub length_m: Option<f64>,
136 pub drag_model: DragModelV1,
137 pub ballistic_coefficient: f64,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142pub enum DragModelV1 {
143 #[serde(rename = "G1")]
144 G1,
145 #[serde(rename = "G6")]
146 G6,
147 #[serde(rename = "G7")]
148 G7,
149 #[serde(rename = "G8")]
150 G8,
151}
152
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(deny_unknown_fields)]
156pub struct RifleV1 {
157 pub muzzle_velocity_mps: f64,
158 #[serde(
159 default,
160 skip_serializing_if = "Option::is_none",
161 deserialize_with = "deserialize_present"
162 )]
163 pub sight_height_m: Option<f64>,
164 #[serde(
165 default,
166 skip_serializing_if = "Option::is_none",
167 deserialize_with = "deserialize_present"
168 )]
169 pub muzzle_height_m: Option<f64>,
170 #[serde(
171 default,
172 skip_serializing_if = "Option::is_none",
173 deserialize_with = "deserialize_present"
174 )]
175 pub twist_rate_m_per_turn: Option<f64>,
176 #[serde(
177 default,
178 skip_serializing_if = "Option::is_none",
179 deserialize_with = "deserialize_present"
180 )]
181 pub twist_direction: Option<TwistDirectionV1>,
182}
183
184#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum TwistDirectionV1 {
188 Left,
189 #[default]
190 Right,
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct ShotV1 {
197 pub max_range_m: f64,
198 #[serde(
199 default,
200 skip_serializing_if = "Option::is_none",
201 deserialize_with = "deserialize_present"
202 )]
203 pub zero_distance_m: Option<f64>,
204 #[serde(
205 default,
206 skip_serializing_if = "Option::is_none",
207 deserialize_with = "deserialize_present"
208 )]
209 pub muzzle_angle_rad: Option<f64>,
210 #[serde(
211 default,
212 skip_serializing_if = "Option::is_none",
213 deserialize_with = "deserialize_present"
214 )]
215 pub aim_azimuth_rad: Option<f64>,
216 #[serde(
217 default,
218 skip_serializing_if = "Option::is_none",
219 deserialize_with = "deserialize_present"
220 )]
221 pub shot_azimuth_rad: Option<f64>,
222 #[serde(
223 default,
224 skip_serializing_if = "Option::is_none",
225 deserialize_with = "deserialize_present"
226 )]
227 pub shooting_angle_rad: Option<f64>,
228 #[serde(
229 default,
230 skip_serializing_if = "Option::is_none",
231 deserialize_with = "deserialize_present"
232 )]
233 pub cant_angle_rad: Option<f64>,
234 #[serde(
235 default,
236 skip_serializing_if = "Option::is_none",
237 deserialize_with = "deserialize_present"
238 )]
239 pub target_height_m: Option<f64>,
240 #[serde(
241 default,
242 skip_serializing_if = "Option::is_none",
243 deserialize_with = "deserialize_present"
244 )]
245 pub ground_threshold_m: Option<f64>,
246}
247
248#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251pub struct AtmosphereV1 {
252 #[serde(
253 default,
254 skip_serializing_if = "Option::is_none",
255 deserialize_with = "deserialize_present"
256 )]
257 pub altitude_m: Option<f64>,
258 #[serde(
261 default,
262 skip_serializing_if = "Option::is_none",
263 deserialize_with = "deserialize_present"
264 )]
265 pub temperature_k: Option<f64>,
266 #[serde(
272 default,
273 skip_serializing_if = "Option::is_none",
274 deserialize_with = "deserialize_present"
275 )]
276 pub pressure_pa: Option<f64>,
277 #[serde(
283 default,
284 skip_serializing_if = "Option::is_none",
285 deserialize_with = "deserialize_present"
286 )]
287 pub pressure_reference: Option<PressureReferenceV1>,
288 #[serde(
289 default,
290 skip_serializing_if = "Option::is_none",
291 deserialize_with = "deserialize_present"
292 )]
293 pub relative_humidity: Option<f64>,
294 #[serde(
295 default,
296 skip_serializing_if = "Option::is_none",
297 deserialize_with = "deserialize_present"
298 )]
299 pub latitude_rad: Option<f64>,
300}
301
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(rename_all = "snake_case")]
307pub enum PressureReferenceV1 {
308 #[default]
309 Absolute,
310 Qnh,
311}
312
313#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct WindV1 {
320 #[serde(
321 default,
322 skip_serializing_if = "Option::is_none",
323 deserialize_with = "deserialize_present"
324 )]
325 pub speed_mps: Option<f64>,
326 #[serde(
327 default,
328 skip_serializing_if = "Option::is_none",
329 deserialize_with = "deserialize_present"
330 )]
331 pub direction_from_rad: Option<f64>,
332 #[serde(
333 default,
334 skip_serializing_if = "Option::is_none",
335 deserialize_with = "deserialize_present"
336 )]
337 pub vertical_speed_mps: Option<f64>,
338 #[serde(
339 default,
340 skip_serializing_if = "Option::is_none",
341 deserialize_with = "deserialize_present"
342 )]
343 pub segments: Option<Vec<WindSegmentV1>>,
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348#[serde(deny_unknown_fields)]
349pub struct WindSegmentV1 {
350 pub until_distance_m: f64,
351 pub speed_mps: f64,
352 pub direction_from_rad: f64,
353 #[serde(
354 default,
355 skip_serializing_if = "Option::is_none",
356 deserialize_with = "deserialize_present"
357 )]
358 pub vertical_speed_mps: Option<f64>,
359}
360
361#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(deny_unknown_fields)]
364pub struct SolverV1 {
365 #[serde(
366 default,
367 skip_serializing_if = "Option::is_none",
368 deserialize_with = "deserialize_present"
369 )]
370 pub method: Option<SolverMethodV1>,
371 #[serde(
373 default,
374 skip_serializing_if = "Option::is_none",
375 deserialize_with = "deserialize_present"
376 )]
377 pub time_step_s: Option<f64>,
378}
379
380#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum SolverMethodV1 {
384 Euler,
385 Rk4,
386 #[default]
387 Rk45,
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct EffectsV1 {
394 #[serde(
395 default,
396 skip_serializing_if = "Option::is_none",
397 deserialize_with = "deserialize_present"
398 )]
399 pub magnus: Option<bool>,
400 #[serde(
401 default,
402 skip_serializing_if = "Option::is_none",
403 deserialize_with = "deserialize_present"
404 )]
405 pub coriolis: Option<bool>,
406 #[serde(
407 default,
408 skip_serializing_if = "Option::is_none",
409 deserialize_with = "deserialize_present"
410 )]
411 pub enhanced_spin_drift: Option<bool>,
412}
413
414#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
416#[serde(deny_unknown_fields)]
417pub struct SamplingV1 {
418 #[serde(
419 default,
420 skip_serializing_if = "Option::is_none",
421 deserialize_with = "deserialize_present"
422 )]
423 pub interval_m: Option<f64>,
424}
425
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
432#[serde(deny_unknown_fields)]
433pub struct ResolvedSolveRequestV1 {
434 pub schema_version: SchemaVersionV1,
435 pub projectile: ResolvedProjectileV1,
436 pub rifle: ResolvedRifleV1,
437 pub shot: ResolvedShotV1,
438 pub atmosphere: ResolvedAtmosphereV1,
439 pub wind: ResolvedWindV1,
440 pub solver: ResolvedSolverV1,
441 pub effects: ResolvedEffectsV1,
442 pub sampling: ResolvedSamplingV1,
443}
444
445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct ResolvedProjectileV1 {
449 pub mass_kg: f64,
450 pub diameter_m: f64,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub length_m: Option<f64>,
453 pub drag_model: DragModelV1,
454 pub ballistic_coefficient: f64,
455}
456
457#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460pub struct ResolvedRifleV1 {
461 pub muzzle_velocity_mps: f64,
462 pub sight_height_m: f64,
463 pub muzzle_height_m: f64,
464 pub twist_rate_m_per_turn: f64,
465 pub twist_direction: TwistDirectionV1,
466}
467
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct ResolvedShotV1 {
475 pub max_range_m: f64,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub zero_distance_m: Option<f64>,
478 pub muzzle_angle_rad: f64,
479 pub aim_azimuth_rad: f64,
480 pub shot_azimuth_rad: f64,
481 pub shooting_angle_rad: f64,
482 pub cant_angle_rad: f64,
483 pub target_height_m: f64,
484 pub ground_threshold_m: f64,
485}
486
487#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
489#[serde(deny_unknown_fields)]
490pub struct ResolvedAtmosphereV1 {
491 pub altitude_m: f64,
492 pub temperature_k: f64,
493 pub pressure_pa: f64,
494 pub relative_humidity: f64,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub latitude_rad: Option<f64>,
497}
498
499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504#[serde(untagged)]
505pub enum ResolvedWindV1 {
506 Constant(ResolvedConstantWindV1),
507 Segmented(ResolvedSegmentedWindV1),
508}
509
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
512#[serde(deny_unknown_fields)]
513pub struct ResolvedConstantWindV1 {
514 pub speed_mps: f64,
515 pub direction_from_rad: f64,
516 pub vertical_speed_mps: f64,
517}
518
519#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct ResolvedSegmentedWindV1 {
523 pub segments: Vec<ResolvedWindSegmentV1>,
524}
525
526#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct ResolvedWindSegmentV1 {
530 pub until_distance_m: f64,
531 pub speed_mps: f64,
532 pub direction_from_rad: f64,
533 pub vertical_speed_mps: f64,
534}
535
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
538#[serde(deny_unknown_fields)]
539pub struct ResolvedSolverV1 {
540 pub method: SolverMethodV1,
541 pub time_step_s: f64,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(deny_unknown_fields)]
547pub struct ResolvedEffectsV1 {
548 pub magnus: bool,
549 pub coriolis: bool,
550 pub enhanced_spin_drift: bool,
551}
552
553#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
555#[serde(deny_unknown_fields)]
556pub struct ResolvedSamplingV1 {
557 pub interval_m: f64,
558}
559
560#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
562#[serde(deny_unknown_fields)]
563pub struct SolveSuccessV1 {
564 pub schema_version: SchemaVersionV1,
565 pub engine_version: String,
566 pub status: SuccessStatusV1,
567 pub resolved_request: ResolvedSolveRequestV1,
568 #[serde(default)]
569 pub assumptions: Vec<SolveNoticeV1>,
570 #[serde(default)]
571 pub warnings: Vec<SolveNoticeV1>,
572 pub summary: SolveSummaryV1,
573 #[serde(default, serialize_with = "serialize_solve_samples_v1")]
574 pub samples: Vec<TrajectorySampleV1>,
575}
576
577fn serialize_solve_samples_v1<S>(
578 samples: &[TrajectorySampleV1],
579 serializer: S,
580) -> Result<S::Ok, S::Error>
581where
582 S: Serializer,
583{
584 if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
585 return Err(serde::ser::Error::custom(format_args!(
586 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
587 samples.len()
588 )));
589 }
590 samples.serialize(serializer)
591}
592
593impl SolveSuccessV1 {
594 pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
600 if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
601 return Ok(());
602 }
603
604 Err(SolveErrorEnvelopeV1::new(
605 SolveErrorV1::new(
606 SolveErrorCodeV1::ResourceLimit,
607 format!(
608 "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
609 self.samples.len()
610 ),
611 )
612 .at_path("$.sampling.interval_m"),
613 ))
614 }
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "snake_case")]
620pub enum SuccessStatusV1 {
621 Ok,
622}
623
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626#[serde(deny_unknown_fields)]
627pub struct SolveNoticeV1 {
628 pub code: String,
629 pub message: String,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub path: Option<String>,
632}
633
634#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636#[serde(deny_unknown_fields)]
637pub struct SolveSummaryV1 {
638 pub actual_range_m: f64,
639 pub maximum_height_m: f64,
642 pub time_of_flight_s: f64,
643 pub terminal_speed_mps: f64,
644 pub terminal_energy_j: f64,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub stability_factor: Option<f64>,
649 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub spin_drift_m: Option<f64>,
654 pub termination: TerminationReasonV1,
655}
656
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(rename_all = "snake_case")]
660pub enum TerminationReasonV1 {
661 MaxRange,
662 GroundThreshold,
663 TimeLimit,
664 VelocityFloor,
665}
666
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct TrajectorySampleV1 {
671 pub distance_m: f64,
672 pub time_s: f64,
673 pub speed_mps: f64,
674 pub energy_j: f64,
675 pub drop_m: f64,
677 pub windage_m: f64,
679 pub mach: f64,
680 #[serde(default)]
681 pub flags: Vec<SampleFlagV1>,
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "snake_case")]
687pub enum SampleFlagV1 {
688 Transonic,
689 Subsonic,
690 Terminal,
691 GroundThreshold,
692}
693
694#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct SolveErrorEnvelopeV1 {
698 pub schema_version: SchemaVersionV1,
699 pub status: ErrorStatusV1,
700 pub error: SolveErrorV1,
701}
702
703impl SolveErrorEnvelopeV1 {
704 pub fn new(error: SolveErrorV1) -> Self {
706 Self {
707 schema_version: SchemaVersionV1,
708 status: ErrorStatusV1::Error,
709 error,
710 }
711 }
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
716#[serde(rename_all = "snake_case")]
717pub enum ErrorStatusV1 {
718 Error,
719}
720
721#[derive(Debug, Clone, PartialEq, Eq)]
727pub struct SolveErrorV1 {
728 pub code: SolveErrorCodeV1,
729 pub message: String,
730 location: SolveErrorLocationV1,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
734enum SolveErrorLocationV1 {
735 None,
736 Path(String),
737 Source {
738 line: NonZeroUsize,
739 column: NonZeroUsize,
740 },
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub enum SolveErrorLocationErrorV1 {
746 ZeroLine,
747 ZeroColumn,
748}
749
750impl fmt::Display for SolveErrorLocationErrorV1 {
751 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
752 match self {
753 Self::ZeroLine => formatter.write_str("error line must be one-based"),
754 Self::ZeroColumn => formatter.write_str("error column must be one-based"),
755 }
756 }
757}
758
759impl std::error::Error for SolveErrorLocationErrorV1 {}
760
761impl SolveErrorV1 {
762 pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
764 Self {
765 code,
766 message: message.into(),
767 location: SolveErrorLocationV1::None,
768 }
769 }
770
771 pub fn at_path(mut self, path: impl Into<String>) -> Self {
773 self.location = SolveErrorLocationV1::Path(path.into());
774 self
775 }
776
777 pub fn at_location(
779 mut self,
780 line: usize,
781 column: usize,
782 ) -> Result<Self, SolveErrorLocationErrorV1> {
783 let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
784 let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
785 self.location = SolveErrorLocationV1::Source { line, column };
786 Ok(self)
787 }
788
789 fn at_parser_location(mut self, line: usize, column: usize) -> Self {
794 self.location = SolveErrorLocationV1::Source {
795 line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
796 column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
797 };
798 self
799 }
800
801 pub fn path(&self) -> Option<&str> {
803 match &self.location {
804 SolveErrorLocationV1::Path(path) => Some(path),
805 SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
806 }
807 }
808
809 pub fn line(&self) -> Option<usize> {
811 match &self.location {
812 SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
813 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
814 }
815 }
816
817 pub fn column(&self) -> Option<usize> {
819 match &self.location {
820 SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
821 SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
822 }
823 }
824}
825
826impl Serialize for SolveErrorV1 {
827 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
828 where
829 S: Serializer,
830 {
831 let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
832 state.serialize_field("code", &self.code)?;
833 state.serialize_field("message", &self.message)?;
834 state.serialize_field("path", &self.path())?;
835 state.serialize_field("line", &self.line())?;
836 state.serialize_field("column", &self.column())?;
837 state.end()
838 }
839}
840
841impl<'de> Deserialize<'de> for SolveErrorV1 {
842 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
843 where
844 D: Deserializer<'de>,
845 {
846 #[derive(Deserialize)]
847 #[serde(deny_unknown_fields)]
848 struct SolveErrorWireV1 {
849 code: SolveErrorCodeV1,
850 message: String,
851 path: Option<String>,
852 line: Option<usize>,
853 column: Option<usize>,
854 }
855
856 let wire = SolveErrorWireV1::deserialize(deserializer)?;
857 let location = match (wire.path, wire.line, wire.column) {
858 (None, None, None) => SolveErrorLocationV1::None,
859 (Some(path), None, None) => SolveErrorLocationV1::Path(path),
860 (None, Some(line), Some(column)) => {
861 let line = NonZeroUsize::new(line)
862 .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
863 let column = NonZeroUsize::new(column)
864 .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
865 SolveErrorLocationV1::Source { line, column }
866 }
867 _ => {
868 return Err(de::Error::custom(
869 "error location must contain either path or both line and column",
870 ));
871 }
872 };
873
874 Ok(Self {
875 code: wire.code,
876 message: wire.message,
877 location,
878 })
879 }
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
884#[serde(rename_all = "snake_case")]
885pub enum SolveErrorCodeV1 {
886 InvalidJson,
887 UnsupportedSchemaVersion,
888 UnknownField,
889 MissingField,
890 InvalidValue,
891 ConflictingFields,
892 ResourceLimit,
893 SolveFailed,
894 IoError,
895 InternalError,
896}
897
898pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
905 let value: Value = serde_json::from_str(input).map_err(|error| {
906 let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
907 .at_parser_location(error.line(), error.column());
908 envelope(error)
909 })?;
910
911 validate_request_shape(&value)?;
912
913 let request: SolveRequestV1 = serde_json::from_value(value).map_err(|error| {
914 envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
915 })?;
916
917 validate_request_ranges(&request)?;
918
919 Ok(request)
920}
921
922mod limits {
935 pub const MASS_KG: (f64, f64) = (1.0e-6, 100.0);
939 pub const DIAMETER_M: (f64, f64) = (1.0e-4, 1.0);
941 pub const LENGTH_M: (f64, f64) = (1.0e-4, 10.0);
943 pub const BALLISTIC_COEFFICIENT: (f64, f64) = (1.0e-4, 100.0);
945
946 }
953
954fn require_range(
955 value: f64,
956 (min, max): (f64, f64),
957 path: &str,
958) -> Result<(), SolveErrorEnvelopeV1> {
959 if !value.is_finite() {
960 return Err(protocol_error(
961 SolveErrorCodeV1::InvalidValue,
962 format!("{path} must be a finite number"),
963 path,
964 ));
965 }
966 if value < min || value > max {
967 return Err(protocol_error(
968 SolveErrorCodeV1::InvalidValue,
969 format!("{path} must be between {min} and {max}, got {value}"),
970 path,
971 ));
972 }
973 Ok(())
974}
975
976fn validate_request_ranges(request: &SolveRequestV1) -> Result<(), SolveErrorEnvelopeV1> {
981 require_range(
982 request.projectile.mass_kg,
983 limits::MASS_KG,
984 "$.projectile.mass_kg",
985 )?;
986 require_range(
987 request.projectile.diameter_m,
988 limits::DIAMETER_M,
989 "$.projectile.diameter_m",
990 )?;
991 require_range(
992 request.projectile.ballistic_coefficient,
993 limits::BALLISTIC_COEFFICIENT,
994 "$.projectile.ballistic_coefficient",
995 )?;
996 if let Some(length_m) = request.projectile.length_m {
997 require_range(length_m, limits::LENGTH_M, "$.projectile.length_m")?;
998 }
999 Ok(())
1000}
1001
1002fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
1003 SolveErrorEnvelopeV1::new(error)
1004}
1005
1006fn protocol_error(
1007 code: SolveErrorCodeV1,
1008 message: impl Into<String>,
1009 path: impl Into<String>,
1010) -> SolveErrorEnvelopeV1 {
1011 envelope(SolveErrorV1::new(code, message).at_path(path))
1012}
1013
1014fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1015 let root = require_object(value, "$")?;
1016
1017 validate_schema_version(root)?;
1021 validate_members(
1022 root,
1023 "$",
1024 &[
1025 "schema_version",
1026 "projectile",
1027 "rifle",
1028 "shot",
1029 "atmosphere",
1030 "wind",
1031 "solver",
1032 "effects",
1033 "sampling",
1034 ],
1035 &[
1036 "schema_version",
1037 "projectile",
1038 "rifle",
1039 "shot",
1040 "atmosphere",
1041 "wind",
1042 "solver",
1043 "effects",
1044 "sampling",
1045 ],
1046 )?;
1047
1048 validate_projectile(required_value(root, "projectile", "$")?)?;
1049 validate_rifle(required_value(root, "rifle", "$")?)?;
1050 validate_shot(required_value(root, "shot", "$")?)?;
1051 validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
1052 validate_wind(required_value(root, "wind", "$")?)?;
1053 validate_solver(required_value(root, "solver", "$")?)?;
1054 validate_effects(required_value(root, "effects", "$")?)?;
1055 validate_sampling(required_value(root, "sampling", "$")?)?;
1056 Ok(())
1057}
1058
1059fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
1060 let value = required_value(root, "schema_version", "$")?;
1061 let version = if let Some(version) = value.as_i64() {
1062 i128::from(version)
1063 } else if let Some(version) = value.as_u64() {
1064 i128::from(version)
1065 } else {
1066 return Err(protocol_error(
1067 SolveErrorCodeV1::InvalidValue,
1068 "schema_version must be the integer 1",
1069 "$.schema_version",
1070 ));
1071 };
1072 if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
1073 return Err(protocol_error(
1074 SolveErrorCodeV1::UnsupportedSchemaVersion,
1075 format!(
1076 "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
1077 ),
1078 "$.schema_version",
1079 ));
1080 }
1081 Ok(())
1082}
1083
1084fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1085 let path = "$.projectile";
1086 let object = require_object(value, path)?;
1087 validate_members(
1088 object,
1089 path,
1090 &[
1091 "mass_kg",
1092 "diameter_m",
1093 "length_m",
1094 "drag_model",
1095 "ballistic_coefficient",
1096 ],
1097 &[
1098 "mass_kg",
1099 "diameter_m",
1100 "drag_model",
1101 "ballistic_coefficient",
1102 ],
1103 )?;
1104 validate_required_numbers(
1105 object,
1106 path,
1107 &["mass_kg", "diameter_m", "ballistic_coefficient"],
1108 )?;
1109 validate_optional_number(object, path, "length_m")?;
1110 validate_string_enum(
1111 required_value(object, "drag_model", path)?,
1112 "$.projectile.drag_model",
1113 &["G1", "G6", "G7", "G8"],
1114 )
1115}
1116
1117fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1118 let path = "$.rifle";
1119 let object = require_object(value, path)?;
1120 validate_members(
1121 object,
1122 path,
1123 &[
1124 "muzzle_velocity_mps",
1125 "sight_height_m",
1126 "muzzle_height_m",
1127 "twist_rate_m_per_turn",
1128 "twist_direction",
1129 ],
1130 &["muzzle_velocity_mps"],
1131 )?;
1132 validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1133 validate_optional_numbers(
1134 object,
1135 path,
1136 &["sight_height_m", "muzzle_height_m", "twist_rate_m_per_turn"],
1137 )?;
1138 if let Some(direction) = object.get("twist_direction") {
1139 validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1140 }
1141 Ok(())
1142}
1143
1144fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1145 let path = "$.shot";
1146 let object = require_object(value, path)?;
1147 validate_members(
1148 object,
1149 path,
1150 &[
1151 "max_range_m",
1152 "zero_distance_m",
1153 "muzzle_angle_rad",
1154 "aim_azimuth_rad",
1155 "shot_azimuth_rad",
1156 "shooting_angle_rad",
1157 "cant_angle_rad",
1158 "target_height_m",
1159 "ground_threshold_m",
1160 ],
1161 &["max_range_m"],
1162 )?;
1163 validate_required_numbers(object, path, &["max_range_m"])?;
1164 validate_optional_number(object, path, "zero_distance_m")?;
1165 validate_optional_number(object, path, "muzzle_angle_rad")?;
1166 validate_optional_numbers(
1167 object,
1168 path,
1169 &[
1170 "aim_azimuth_rad",
1171 "shot_azimuth_rad",
1172 "shooting_angle_rad",
1173 "cant_angle_rad",
1174 "target_height_m",
1175 "ground_threshold_m",
1176 ],
1177 )
1178}
1179
1180fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1181 let path = "$.atmosphere";
1182 let object = require_object(value, path)?;
1183 validate_members(
1184 object,
1185 path,
1186 &[
1187 "altitude_m",
1188 "temperature_k",
1189 "pressure_pa",
1190 "relative_humidity",
1191 "latitude_rad",
1192 ],
1193 &[],
1194 )?;
1195 validate_optional_numbers(
1196 object,
1197 path,
1198 &[
1199 "altitude_m",
1200 "temperature_k",
1201 "pressure_pa",
1202 "relative_humidity",
1203 "latitude_rad",
1204 ],
1205 )
1206}
1207
1208fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1209 let path = "$.wind";
1210 let object = require_object(value, path)?;
1211 validate_members(
1212 object,
1213 path,
1214 &[
1215 "speed_mps",
1216 "direction_from_rad",
1217 "vertical_speed_mps",
1218 "segments",
1219 ],
1220 &[],
1221 )?;
1222 for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1223 validate_optional_number(object, path, field)?;
1224 }
1225
1226 if let Some(segments) = object.get("segments") {
1227 let Some(segments) = segments.as_array() else {
1228 return Err(protocol_error(
1229 SolveErrorCodeV1::InvalidValue,
1230 "segments must be an array",
1231 "$.wind.segments",
1232 ));
1233 };
1234 for (index, segment) in segments.iter().enumerate() {
1235 let segment_path = format!("$.wind.segments[{index}]");
1236 let segment = require_object(segment, &segment_path)?;
1237 validate_members(
1238 segment,
1239 &segment_path,
1240 &[
1241 "until_distance_m",
1242 "speed_mps",
1243 "direction_from_rad",
1244 "vertical_speed_mps",
1245 ],
1246 &["until_distance_m", "speed_mps", "direction_from_rad"],
1247 )?;
1248 validate_required_numbers(
1249 segment,
1250 &segment_path,
1251 &["until_distance_m", "speed_mps", "direction_from_rad"],
1252 )?;
1253 validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1254 }
1255 }
1256 Ok(())
1257}
1258
1259fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1260 let path = "$.solver";
1261 let object = require_object(value, path)?;
1262 validate_members(object, path, &["method", "time_step_s"], &[])?;
1263 validate_optional_number(object, path, "time_step_s")?;
1264 if let Some(method) = object.get("method") {
1265 validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1266 }
1267 Ok(())
1268}
1269
1270fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1271 let path = "$.effects";
1272 let object = require_object(value, path)?;
1273 validate_members(
1274 object,
1275 path,
1276 &["magnus", "coriolis", "enhanced_spin_drift"],
1277 &[],
1278 )?;
1279 validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1280
1281 if object.get("magnus").and_then(Value::as_bool) == Some(true)
1282 && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1283 {
1284 return Err(protocol_error(
1285 SolveErrorCodeV1::ConflictingFields,
1286 "magnus and enhanced_spin_drift cannot both be enabled",
1287 "$.effects",
1288 ));
1289 }
1290
1291 Ok(())
1292}
1293
1294fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1295 let path = "$.sampling";
1296 let object = require_object(value, path)?;
1297 validate_members(object, path, &["interval_m"], &[])?;
1298 validate_optional_number(object, path, "interval_m")
1299}
1300
1301fn require_object<'a>(
1302 value: &'a Value,
1303 path: &str,
1304) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1305 value
1306 .as_object()
1307 .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1308}
1309
1310fn required_value<'a>(
1311 object: &'a Map<String, Value>,
1312 field: &str,
1313 parent_path: &str,
1314) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1315 object.get(field).ok_or_else(|| {
1316 protocol_error(
1317 SolveErrorCodeV1::MissingField,
1318 format!("missing required field `{field}`"),
1319 child_path(parent_path, field),
1320 )
1321 })
1322}
1323
1324fn validate_members(
1325 object: &Map<String, Value>,
1326 path: &str,
1327 allowed: &[&str],
1328 required: &[&str],
1329) -> Result<(), SolveErrorEnvelopeV1> {
1330 if let Some(field) = object
1331 .keys()
1332 .find(|field| !allowed.contains(&field.as_str()))
1333 {
1334 return Err(protocol_error(
1335 SolveErrorCodeV1::UnknownField,
1336 format!("unknown field `{field}`"),
1337 child_path(path, field),
1338 ));
1339 }
1340
1341 if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1342 return Err(protocol_error(
1343 SolveErrorCodeV1::MissingField,
1344 format!("missing required field `{field}`"),
1345 child_path(path, field),
1346 ));
1347 }
1348 Ok(())
1349}
1350
1351fn validate_string_enum(
1352 value: &Value,
1353 path: &str,
1354 allowed: &[&str],
1355) -> Result<(), SolveErrorEnvelopeV1> {
1356 let Some(value) = value.as_str() else {
1357 return Err(protocol_error(
1358 SolveErrorCodeV1::InvalidValue,
1359 "expected a string enum value",
1360 path,
1361 ));
1362 };
1363 if !allowed.contains(&value) {
1364 return Err(protocol_error(
1365 SolveErrorCodeV1::InvalidValue,
1366 format!(
1367 "invalid value `{value}`; expected one of {}",
1368 allowed.join(", ")
1369 ),
1370 path,
1371 ));
1372 }
1373 Ok(())
1374}
1375
1376fn validate_required_numbers(
1377 object: &Map<String, Value>,
1378 parent_path: &str,
1379 fields: &[&str],
1380) -> Result<(), SolveErrorEnvelopeV1> {
1381 for field in fields {
1382 let value = required_value(object, field, parent_path)?;
1383 validate_number(value, &child_path(parent_path, field))?;
1384 }
1385 Ok(())
1386}
1387
1388fn validate_optional_numbers(
1389 object: &Map<String, Value>,
1390 parent_path: &str,
1391 fields: &[&str],
1392) -> Result<(), SolveErrorEnvelopeV1> {
1393 for field in fields {
1394 validate_optional_number(object, parent_path, field)?;
1395 }
1396 Ok(())
1397}
1398
1399fn validate_optional_number(
1400 object: &Map<String, Value>,
1401 parent_path: &str,
1402 field: &str,
1403) -> Result<(), SolveErrorEnvelopeV1> {
1404 if let Some(value) = object.get(field) {
1405 validate_number(value, &child_path(parent_path, field))?;
1406 }
1407 Ok(())
1408}
1409
1410fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1411 if value.is_number() {
1412 Ok(())
1413 } else {
1414 Err(protocol_error(
1415 SolveErrorCodeV1::InvalidValue,
1416 "expected a number",
1417 path,
1418 ))
1419 }
1420}
1421
1422fn validate_optional_booleans(
1423 object: &Map<String, Value>,
1424 parent_path: &str,
1425 fields: &[&str],
1426) -> Result<(), SolveErrorEnvelopeV1> {
1427 for field in fields {
1428 if let Some(value) = object.get(*field) {
1429 if !value.is_boolean() {
1430 return Err(protocol_error(
1431 SolveErrorCodeV1::InvalidValue,
1432 "expected a boolean",
1433 child_path(parent_path, field),
1434 ));
1435 }
1436 }
1437 }
1438 Ok(())
1439}
1440
1441fn child_path(parent: &str, field: &str) -> String {
1442 format!("{parent}.{field}")
1443}
1444
1445#[cfg(test)]
1447mod request_range_tests {
1448 use super::*;
1449
1450 fn valid_request_json() -> String {
1453 r#"{"schema_version":1,
1454 "projectile":{"mass_kg":0.01134,"diameter_m":0.00782,"length_m":0.031,
1455 "drag_model":"G7","ballistic_coefficient":0.243},
1456 "rifle":{"muzzle_velocity_mps":823.0},
1457 "shot":{"max_range_m":1000.0},
1458 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#
1459 .to_string()
1460 }
1461
1462 fn decode_err(json: &str) -> SolveErrorEnvelopeV1 {
1463 decode_solve_request_v1(json).expect_err("request should have been rejected")
1464 }
1465
1466 #[test]
1467 fn an_ordinary_request_still_decodes() {
1468 decode_solve_request_v1(&valid_request_json()).expect("a real load must not be rejected");
1469 }
1470
1471 #[test]
1477 fn the_fuzz_reproducer_is_now_a_clean_typed_rejection() {
1478 let reproducer = r#"{"schema_version":1,
1479 "projectile":{"mass_kg":0.011366666667e-64,"diameter_m":0.00782,
1480 "drag_model":"G7","ballistic_coefficient":1.2e2},
1481 "rifle":{"muzzle_velocity_mps":823.0},
1482 "shot":{"max_range_m":100.0},
1483 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#;
1484
1485 let envelope = decode_err(reproducer);
1486 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1487 assert_eq!(envelope.error.path(), Some("$.projectile.mass_kg"));
1488 }
1489
1490 #[test]
1493 fn the_rejection_envelope_round_trips() {
1494 let envelope = decode_err(
1495 r#"{"schema_version":1,
1496 "projectile":{"mass_kg":1.0e-66,"diameter_m":0.00782,
1497 "drag_model":"G7","ballistic_coefficient":0.243},
1498 "rifle":{"muzzle_velocity_mps":823.0},
1499 "shot":{"max_range_m":100.0},
1500 "atmosphere":{},"wind":{},"solver":{},"effects":{},"sampling":{}}"#,
1501 );
1502 let encoded = serde_json::to_string(&envelope).expect("serialize");
1503 let decoded: SolveErrorEnvelopeV1 = serde_json::from_str(&encoded).expect("deserialize");
1504 assert_eq!(decoded, envelope);
1505 }
1506
1507 #[test]
1508 fn each_bounded_field_reports_its_own_path() {
1509 for (field, bad_value, path) in [
1510 ("mass_kg", "1.0e-66", "$.projectile.mass_kg"),
1511 ("diameter_m", "1.0e-9", "$.projectile.diameter_m"),
1512 (
1513 "ballistic_coefficient",
1514 "1.0e6",
1515 "$.projectile.ballistic_coefficient",
1516 ),
1517 ("length_m", "1.0e-9", "$.projectile.length_m"),
1518 ] {
1519 let json = valid_request_json().replace(
1520 &format!("\"{field}\":{}", default_for(field)),
1521 &format!("\"{field}\":{bad_value}"),
1522 );
1523 let envelope = decode_err(&json);
1524 assert_eq!(
1525 envelope.error.path(),
1526 Some(path),
1527 "wrong path for {field}"
1528 );
1529 assert_eq!(envelope.error.code, SolveErrorCodeV1::InvalidValue);
1530 }
1531 }
1532
1533 fn default_for(field: &str) -> &'static str {
1534 match field {
1535 "mass_kg" => "0.01134",
1536 "diameter_m" => "0.00782",
1537 "ballistic_coefficient" => "0.243",
1538 "length_m" => "0.031",
1539 other => panic!("no default recorded for {other}"),
1540 }
1541 }
1542
1543 #[test]
1547 fn muzzle_velocity_is_deliberately_left_unbounded() {
1548 let json = valid_request_json().replace("823.0", "1.0e308");
1549 decode_solve_request_v1(&json)
1550 .expect("muzzle velocity must stay a solve-time concern, not a protocol one");
1551 }
1552}