Skip to main content

ballistics_engine/
solve_json.rs

1//! Versioned, binding-neutral JSON data-transfer objects for trajectory solves.
2//!
3//! These types deliberately do not expose [`crate::BallisticInputs`]. The public JSON contract
4//! has its own names, units, defaults, and compatibility policy so engine internals can evolve
5//! without silently changing requests produced by language bindings.
6
7use 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
15/// The only solve-json schema version understood by this module.
16pub const SOLVE_JSON_SCHEMA_VERSION_V1: u32 = 1;
17
18/// Maximum number of trajectory observations in one solve-json v1 success response.
19///
20/// Service implementations must reject a response above this limit with
21/// [`SolveSuccessV1::validate_for_serialization`] instead of truncating it.
22pub const MAX_SOLVE_JSON_SAMPLES_V1: usize = 10_000;
23
24/// Deserialize a request member that may be omitted but may not be JSON `null`.
25///
26/// Serde supplies [`Option::None`] from the field's `default` only when the member is absent.
27/// When the member is present, deserialize `T` directly so `null` is rejected for scalar,
28/// enum, object, and array values alike.
29fn 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/// An invariant solve-json v1 schema discriminator.
38///
39/// This unit type has no invalid public state: it always serializes as the JSON integer `1` and
40/// deserializes only from that integer.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
42pub struct SchemaVersionV1;
43
44impl SchemaVersionV1 {
45    /// Return the integer represented on the wire.
46    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/// A complete v1 trajectory-solve request.
106///
107/// All dimensional values use SI units named in the field. Every section is mandatory, even
108/// when all fields in a section have defaults; this keeps the top-level request shape explicit.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct SolveRequestV1 {
112    /// Must be exactly [`SOLVE_JSON_SCHEMA_VERSION_V1`].
113    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/// Projectile inputs supported by solve-json v1.
125#[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/// Built-in reference-projectile drag models supported by solve-json v1.
141#[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/// Rifle and sight geometry.
154#[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/// Direction of rifling twist as viewed from the breech toward the muzzle.
185#[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/// Shot geometry and termination range.
194#[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/// Atmospheric station conditions.
249#[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    /// Authoritative station temperature, or `None` to resolve ICAO standard temperature at
259    /// `altitude_m`.
260    #[serde(
261        default,
262        skip_serializing_if = "Option::is_none",
263        deserialize_with = "deserialize_present"
264    )]
265    pub temperature_k: Option<f64>,
266    /// Authoritative station pressure, or `None` to resolve ICAO standard pressure at
267    /// `altitude_m`.
268    #[serde(
269        default,
270        skip_serializing_if = "Option::is_none",
271        deserialize_with = "deserialize_present"
272    )]
273    pub pressure_pa: Option<f64>,
274    #[serde(
275        default,
276        skip_serializing_if = "Option::is_none",
277        deserialize_with = "deserialize_present"
278    )]
279    pub relative_humidity: Option<f64>,
280    #[serde(
281        default,
282        skip_serializing_if = "Option::is_none",
283        deserialize_with = "deserialize_present"
284    )]
285    pub latitude_rad: Option<f64>,
286}
287
288/// Constant or downrange-segmented wind.
289///
290/// Omit all fields for still air. A constant wind supplies both `speed_mps` and
291/// `direction_from_rad`. Segmented wind supplies `segments` and no constant-wind fields.
292#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
293#[serde(deny_unknown_fields)]
294pub struct WindV1 {
295    #[serde(
296        default,
297        skip_serializing_if = "Option::is_none",
298        deserialize_with = "deserialize_present"
299    )]
300    pub speed_mps: Option<f64>,
301    #[serde(
302        default,
303        skip_serializing_if = "Option::is_none",
304        deserialize_with = "deserialize_present"
305    )]
306    pub direction_from_rad: Option<f64>,
307    #[serde(
308        default,
309        skip_serializing_if = "Option::is_none",
310        deserialize_with = "deserialize_present"
311    )]
312    pub vertical_speed_mps: Option<f64>,
313    #[serde(
314        default,
315        skip_serializing_if = "Option::is_none",
316        deserialize_with = "deserialize_present"
317    )]
318    pub segments: Option<Vec<WindSegmentV1>>,
319}
320
321/// One wind segment, active through `until_distance_m`.
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct WindSegmentV1 {
325    pub until_distance_m: f64,
326    pub speed_mps: f64,
327    pub direction_from_rad: f64,
328    #[serde(
329        default,
330        skip_serializing_if = "Option::is_none",
331        deserialize_with = "deserialize_present"
332    )]
333    pub vertical_speed_mps: Option<f64>,
334}
335
336/// Numerical integration configuration.
337#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
338#[serde(deny_unknown_fields)]
339pub struct SolverV1 {
340    #[serde(
341        default,
342        skip_serializing_if = "Option::is_none",
343        deserialize_with = "deserialize_present"
344    )]
345    pub method: Option<SolverMethodV1>,
346    /// Fixed step for RK4 and Euler. RK45 owns its adaptive step size.
347    #[serde(
348        default,
349        skip_serializing_if = "Option::is_none",
350        deserialize_with = "deserialize_present"
351    )]
352    pub time_step_s: Option<f64>,
353}
354
355/// Integration algorithms exposed by solve-json v1.
356#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "snake_case")]
358pub enum SolverMethodV1 {
359    Euler,
360    Rk4,
361    #[default]
362    Rk45,
363}
364
365/// Optional physical effects supported by solve-json v1.
366#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(deny_unknown_fields)]
368pub struct EffectsV1 {
369    #[serde(
370        default,
371        skip_serializing_if = "Option::is_none",
372        deserialize_with = "deserialize_present"
373    )]
374    pub magnus: Option<bool>,
375    #[serde(
376        default,
377        skip_serializing_if = "Option::is_none",
378        deserialize_with = "deserialize_present"
379    )]
380    pub coriolis: Option<bool>,
381    #[serde(
382        default,
383        skip_serializing_if = "Option::is_none",
384        deserialize_with = "deserialize_present"
385    )]
386    pub enhanced_spin_drift: Option<bool>,
387}
388
389/// Regular downrange output sampling configuration.
390#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct SamplingV1 {
393    #[serde(
394        default,
395        skip_serializing_if = "Option::is_none",
396        deserialize_with = "deserialize_present"
397    )]
398    pub interval_m: Option<f64>,
399}
400
401/// A solve request after the service has applied every documented v1 default.
402///
403/// This is intentionally distinct from [`SolveRequestV1`]. Request DTOs retain whether a
404/// defaulted field was omitted, while resolved DTOs require a concrete value for every default
405/// that affected the solve. Semantically optional inputs remain optional.
406#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct ResolvedSolveRequestV1 {
409    pub schema_version: SchemaVersionV1,
410    pub projectile: ResolvedProjectileV1,
411    pub rifle: ResolvedRifleV1,
412    pub shot: ResolvedShotV1,
413    pub atmosphere: ResolvedAtmosphereV1,
414    pub wind: ResolvedWindV1,
415    pub solver: ResolvedSolverV1,
416    pub effects: ResolvedEffectsV1,
417    pub sampling: ResolvedSamplingV1,
418}
419
420/// Resolved projectile inputs.
421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct ResolvedProjectileV1 {
424    pub mass_kg: f64,
425    pub diameter_m: f64,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub length_m: Option<f64>,
428    pub drag_model: DragModelV1,
429    pub ballistic_coefficient: f64,
430}
431
432/// Resolved rifle and sight geometry.
433#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
434#[serde(deny_unknown_fields)]
435pub struct ResolvedRifleV1 {
436    pub muzzle_velocity_mps: f64,
437    pub sight_height_m: f64,
438    pub muzzle_height_m: f64,
439    pub twist_rate_m_per_turn: f64,
440    pub twist_direction: TwistDirectionV1,
441}
442
443/// Resolved shot geometry.
444///
445/// `zero_distance_m` records caller zeroing intent, while `muzzle_angle_rad` is always the
446/// effective angle used by the engine. A zero-distance solve therefore populates both fields.
447#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
448#[serde(deny_unknown_fields)]
449pub struct ResolvedShotV1 {
450    pub max_range_m: f64,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub zero_distance_m: Option<f64>,
453    pub muzzle_angle_rad: f64,
454    pub aim_azimuth_rad: f64,
455    pub shot_azimuth_rad: f64,
456    pub shooting_angle_rad: f64,
457    pub cant_angle_rad: f64,
458    pub target_height_m: f64,
459    pub ground_threshold_m: f64,
460}
461
462/// Resolved station conditions.
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464#[serde(deny_unknown_fields)]
465pub struct ResolvedAtmosphereV1 {
466    pub altitude_m: f64,
467    pub temperature_k: f64,
468    pub pressure_pa: f64,
469    pub relative_humidity: f64,
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub latitude_rad: Option<f64>,
472}
473
474/// Resolved constant or segmented wind.
475///
476/// The untagged representation retains the request's object shape while making the selected wind
477/// variant explicit in Rust. Still air is a constant wind with three zero values.
478#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
479#[serde(untagged)]
480pub enum ResolvedWindV1 {
481    Constant(ResolvedConstantWindV1),
482    Segmented(ResolvedSegmentedWindV1),
483}
484
485/// Resolved still-air or constant-wind values.
486#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
487#[serde(deny_unknown_fields)]
488pub struct ResolvedConstantWindV1 {
489    pub speed_mps: f64,
490    pub direction_from_rad: f64,
491    pub vertical_speed_mps: f64,
492}
493
494/// Resolved downrange wind segments.
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct ResolvedSegmentedWindV1 {
498    pub segments: Vec<ResolvedWindSegmentV1>,
499}
500
501/// One resolved wind segment.
502#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
503#[serde(deny_unknown_fields)]
504pub struct ResolvedWindSegmentV1 {
505    pub until_distance_m: f64,
506    pub speed_mps: f64,
507    pub direction_from_rad: f64,
508    pub vertical_speed_mps: f64,
509}
510
511/// Resolved numerical integration configuration.
512#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
513#[serde(deny_unknown_fields)]
514pub struct ResolvedSolverV1 {
515    pub method: SolverMethodV1,
516    pub time_step_s: f64,
517}
518
519/// Resolved physical-effect switches.
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(deny_unknown_fields)]
522pub struct ResolvedEffectsV1 {
523    pub magnus: bool,
524    pub coriolis: bool,
525    pub enhanced_spin_drift: bool,
526}
527
528/// Resolved result-sampling configuration.
529#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
530#[serde(deny_unknown_fields)]
531pub struct ResolvedSamplingV1 {
532    pub interval_m: f64,
533}
534
535/// A successful solve-json v1 response.
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
537#[serde(deny_unknown_fields)]
538pub struct SolveSuccessV1 {
539    pub schema_version: SchemaVersionV1,
540    pub engine_version: String,
541    pub status: SuccessStatusV1,
542    pub resolved_request: ResolvedSolveRequestV1,
543    #[serde(default)]
544    pub assumptions: Vec<SolveNoticeV1>,
545    #[serde(default)]
546    pub warnings: Vec<SolveNoticeV1>,
547    pub summary: SolveSummaryV1,
548    #[serde(default, serialize_with = "serialize_solve_samples_v1")]
549    pub samples: Vec<TrajectorySampleV1>,
550}
551
552fn serialize_solve_samples_v1<S>(
553    samples: &[TrajectorySampleV1],
554    serializer: S,
555) -> Result<S::Ok, S::Error>
556where
557    S: Serializer,
558{
559    if samples.len() > MAX_SOLVE_JSON_SAMPLES_V1 {
560        return Err(serde::ser::Error::custom(format_args!(
561            "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
562            samples.len()
563        )));
564    }
565    samples.serialize(serializer)
566}
567
568impl SolveSuccessV1 {
569    /// Validate service-level response limits immediately before serialization.
570    ///
571    /// The exact limit is accepted. A response with more samples returns a structured
572    /// [`SolveErrorCodeV1::ResourceLimit`] error at the sampling interval that requested the
573    /// oversized result. The response is never silently truncated.
574    pub fn validate_for_serialization(&self) -> Result<(), SolveErrorEnvelopeV1> {
575        if self.samples.len() <= MAX_SOLVE_JSON_SAMPLES_V1 {
576            return Ok(());
577        }
578
579        Err(SolveErrorEnvelopeV1::new(
580            SolveErrorV1::new(
581                SolveErrorCodeV1::ResourceLimit,
582                format!(
583                    "solve-json v1 response sample limit of {MAX_SOLVE_JSON_SAMPLES_V1} exceeded: response has {} samples",
584                    self.samples.len()
585                ),
586            )
587            .at_path("$.sampling.interval_m"),
588        ))
589    }
590}
591
592/// The success discriminator serialized in a response envelope.
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
594#[serde(rename_all = "snake_case")]
595pub enum SuccessStatusV1 {
596    Ok,
597}
598
599/// A machine-readable assumption or warning.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct SolveNoticeV1 {
603    pub code: String,
604    pub message: String,
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub path: Option<String>,
607}
608
609/// Aggregate observations for a completed solve.
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
611#[serde(deny_unknown_fields)]
612pub struct SolveSummaryV1 {
613    pub actual_range_m: f64,
614    /// Greatest world-vertical projectile height above the request's local ground/reference
615    /// datum. This is not shot-frame Y and is not height above the line of sight.
616    pub maximum_height_m: f64,
617    pub time_of_flight_s: f64,
618    pub terminal_speed_mps: f64,
619    pub terminal_energy_j: f64,
620    /// Muzzle gyroscopic stability factor Sg evaluated with the resolved projectile, muzzle
621    /// velocity, twist, and station atmosphere; absent only when it cannot be calculated.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub stability_factor: Option<f64>,
624    /// Signed gyroscopic spin-drift contribution at the terminal sample, positive to the
625    /// shooter's right. This excludes wind drift and is absent when the effect is disabled or
626    /// cannot be calculated.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub spin_drift_m: Option<f64>,
629    pub termination: TerminationReasonV1,
630}
631
632/// Why the trajectory ended.
633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
634#[serde(rename_all = "snake_case")]
635pub enum TerminationReasonV1 {
636    MaxRange,
637    GroundThreshold,
638    TimeLimit,
639    VelocityFloor,
640}
641
642/// One regularly sampled trajectory observation.
643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
644#[serde(deny_unknown_fields)]
645pub struct TrajectorySampleV1 {
646    pub distance_m: f64,
647    pub time_s: f64,
648    pub speed_mps: f64,
649    pub energy_j: f64,
650    /// Positive means below the line of sight.
651    pub drop_m: f64,
652    /// Positive means right of the line of sight from the shooter's perspective.
653    pub windage_m: f64,
654    pub mach: f64,
655    #[serde(default)]
656    pub flags: Vec<SampleFlagV1>,
657}
658
659/// Stable annotations for trajectory samples.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "snake_case")]
662pub enum SampleFlagV1 {
663    Transonic,
664    Subsonic,
665    Terminal,
666    GroundThreshold,
667}
668
669/// A failed solve-json v1 response.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(deny_unknown_fields)]
672pub struct SolveErrorEnvelopeV1 {
673    pub schema_version: SchemaVersionV1,
674    pub status: ErrorStatusV1,
675    pub error: SolveErrorV1,
676}
677
678impl SolveErrorEnvelopeV1 {
679    /// Wrap a protocol error in a v1 envelope.
680    pub fn new(error: SolveErrorV1) -> Self {
681        Self {
682            schema_version: SchemaVersionV1,
683            status: ErrorStatusV1::Error,
684            error,
685        }
686    }
687}
688
689/// The error discriminator serialized in a response envelope.
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(rename_all = "snake_case")]
692pub enum ErrorStatusV1 {
693    Error,
694}
695
696/// A stable, machine-readable protocol error.
697///
698/// Location state is private so callers cannot construct a path together with a source location,
699/// a partial line/column pair, or a zero source coordinate. The JSON representation retains the
700/// flat `path`, `line`, and `column` fields.
701#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct SolveErrorV1 {
703    pub code: SolveErrorCodeV1,
704    pub message: String,
705    location: SolveErrorLocationV1,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
709enum SolveErrorLocationV1 {
710    None,
711    Path(String),
712    Source {
713        line: NonZeroUsize,
714        column: NonZeroUsize,
715    },
716}
717
718/// Why a source location could not be attached to a protocol error.
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
720pub enum SolveErrorLocationErrorV1 {
721    ZeroLine,
722    ZeroColumn,
723}
724
725impl fmt::Display for SolveErrorLocationErrorV1 {
726    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
727        match self {
728            Self::ZeroLine => formatter.write_str("error line must be one-based"),
729            Self::ZeroColumn => formatter.write_str("error column must be one-based"),
730        }
731    }
732}
733
734impl std::error::Error for SolveErrorLocationErrorV1 {}
735
736impl SolveErrorV1 {
737    /// Construct an error without input-location information.
738    pub fn new(code: SolveErrorCodeV1, message: impl Into<String>) -> Self {
739        Self {
740            code,
741            message: message.into(),
742            location: SolveErrorLocationV1::None,
743        }
744    }
745
746    /// Attach a JSONPath-like input location, replacing any source location.
747    pub fn at_path(mut self, path: impl Into<String>) -> Self {
748        self.location = SolveErrorLocationV1::Path(path.into());
749        self
750    }
751
752    /// Attach a one-based malformed-JSON source location, replacing any JSON path.
753    pub fn at_location(
754        mut self,
755        line: usize,
756        column: usize,
757    ) -> Result<Self, SolveErrorLocationErrorV1> {
758        let line = NonZeroUsize::new(line).ok_or(SolveErrorLocationErrorV1::ZeroLine)?;
759        let column = NonZeroUsize::new(column).ok_or(SolveErrorLocationErrorV1::ZeroColumn)?;
760        self.location = SolveErrorLocationV1::Source { line, column };
761        Ok(self)
762    }
763
764    /// Attach a parser location while keeping the public wire coordinates one-based.
765    ///
766    /// `serde_json` reports column zero for some end-of-file errors. Normalize either zero
767    /// coordinate to one instead of allowing malformed input to trigger a panic.
768    fn at_parser_location(mut self, line: usize, column: usize) -> Self {
769        self.location = SolveErrorLocationV1::Source {
770            line: NonZeroUsize::new(line).unwrap_or(NonZeroUsize::MIN),
771            column: NonZeroUsize::new(column).unwrap_or(NonZeroUsize::MIN),
772        };
773        self
774    }
775
776    /// Return the JSONPath-like input location, when present.
777    pub fn path(&self) -> Option<&str> {
778        match &self.location {
779            SolveErrorLocationV1::Path(path) => Some(path),
780            SolveErrorLocationV1::None | SolveErrorLocationV1::Source { .. } => None,
781        }
782    }
783
784    /// Return the one-based malformed-JSON source line, when present.
785    pub fn line(&self) -> Option<usize> {
786        match &self.location {
787            SolveErrorLocationV1::Source { line, .. } => Some(line.get()),
788            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
789        }
790    }
791
792    /// Return the one-based malformed-JSON source column, when present.
793    pub fn column(&self) -> Option<usize> {
794        match &self.location {
795            SolveErrorLocationV1::Source { column, .. } => Some(column.get()),
796            SolveErrorLocationV1::None | SolveErrorLocationV1::Path(_) => None,
797        }
798    }
799}
800
801impl Serialize for SolveErrorV1 {
802    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
803    where
804        S: Serializer,
805    {
806        let mut state = serializer.serialize_struct("SolveErrorV1", 5)?;
807        state.serialize_field("code", &self.code)?;
808        state.serialize_field("message", &self.message)?;
809        state.serialize_field("path", &self.path())?;
810        state.serialize_field("line", &self.line())?;
811        state.serialize_field("column", &self.column())?;
812        state.end()
813    }
814}
815
816impl<'de> Deserialize<'de> for SolveErrorV1 {
817    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
818    where
819        D: Deserializer<'de>,
820    {
821        #[derive(Deserialize)]
822        #[serde(deny_unknown_fields)]
823        struct SolveErrorWireV1 {
824            code: SolveErrorCodeV1,
825            message: String,
826            path: Option<String>,
827            line: Option<usize>,
828            column: Option<usize>,
829        }
830
831        let wire = SolveErrorWireV1::deserialize(deserializer)?;
832        let location = match (wire.path, wire.line, wire.column) {
833            (None, None, None) => SolveErrorLocationV1::None,
834            (Some(path), None, None) => SolveErrorLocationV1::Path(path),
835            (None, Some(line), Some(column)) => {
836                let line = NonZeroUsize::new(line)
837                    .ok_or_else(|| de::Error::custom("error line must be one-based"))?;
838                let column = NonZeroUsize::new(column)
839                    .ok_or_else(|| de::Error::custom("error column must be one-based"))?;
840                SolveErrorLocationV1::Source { line, column }
841            }
842            _ => {
843                return Err(de::Error::custom(
844                    "error location must contain either path or both line and column",
845                ));
846            }
847        };
848
849        Ok(Self {
850            code: wire.code,
851            message: wire.message,
852            location,
853        })
854    }
855}
856
857/// Stable solve-json v1 error categories.
858#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
859#[serde(rename_all = "snake_case")]
860pub enum SolveErrorCodeV1 {
861    InvalidJson,
862    UnsupportedSchemaVersion,
863    UnknownField,
864    MissingField,
865    InvalidValue,
866    ConflictingFields,
867    ResourceLimit,
868    SolveFailed,
869    IoError,
870    InternalError,
871}
872
873/// Decode and structurally validate one solve-json v1 request.
874///
875/// In addition to Serde's `deny_unknown_fields` enforcement, this entry point attaches exact
876/// JSONPath-like locations to unknown fields, missing required fields, invalid enums, and an
877/// unsupported schema version. Transport implementations should use this function rather than
878/// deserializing [`SolveRequestV1`] directly when they need a protocol error envelope.
879pub fn decode_solve_request_v1(input: &str) -> Result<SolveRequestV1, SolveErrorEnvelopeV1> {
880    let value: Value = serde_json::from_str(input).map_err(|error| {
881        let error = SolveErrorV1::new(SolveErrorCodeV1::InvalidJson, error.to_string())
882            .at_parser_location(error.line(), error.column());
883        envelope(error)
884    })?;
885
886    validate_request_shape(&value)?;
887
888    serde_json::from_value(value).map_err(|error| {
889        envelope(SolveErrorV1::new(SolveErrorCodeV1::InvalidValue, error.to_string()).at_path("$"))
890    })
891}
892
893fn envelope(error: SolveErrorV1) -> SolveErrorEnvelopeV1 {
894    SolveErrorEnvelopeV1::new(error)
895}
896
897fn protocol_error(
898    code: SolveErrorCodeV1,
899    message: impl Into<String>,
900    path: impl Into<String>,
901) -> SolveErrorEnvelopeV1 {
902    envelope(SolveErrorV1::new(code, message).at_path(path))
903}
904
905fn validate_request_shape(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
906    let root = require_object(value, "$")?;
907
908    // Dispatch on the version before applying any v1-only shape rules. A future-version
909    // request may legitimately contain fields unknown to v1 and must still receive the stable
910    // unsupported-version error rather than an order-dependent unknown-field error.
911    validate_schema_version(root)?;
912    validate_members(
913        root,
914        "$",
915        &[
916            "schema_version",
917            "projectile",
918            "rifle",
919            "shot",
920            "atmosphere",
921            "wind",
922            "solver",
923            "effects",
924            "sampling",
925        ],
926        &[
927            "schema_version",
928            "projectile",
929            "rifle",
930            "shot",
931            "atmosphere",
932            "wind",
933            "solver",
934            "effects",
935            "sampling",
936        ],
937    )?;
938
939    validate_projectile(required_value(root, "projectile", "$")?)?;
940    validate_rifle(required_value(root, "rifle", "$")?)?;
941    validate_shot(required_value(root, "shot", "$")?)?;
942    validate_atmosphere(required_value(root, "atmosphere", "$")?)?;
943    validate_wind(required_value(root, "wind", "$")?)?;
944    validate_solver(required_value(root, "solver", "$")?)?;
945    validate_effects(required_value(root, "effects", "$")?)?;
946    validate_sampling(required_value(root, "sampling", "$")?)?;
947    Ok(())
948}
949
950fn validate_schema_version(root: &Map<String, Value>) -> Result<(), SolveErrorEnvelopeV1> {
951    let value = required_value(root, "schema_version", "$")?;
952    let version = if let Some(version) = value.as_i64() {
953        i128::from(version)
954    } else if let Some(version) = value.as_u64() {
955        i128::from(version)
956    } else {
957        return Err(protocol_error(
958            SolveErrorCodeV1::InvalidValue,
959            "schema_version must be the integer 1",
960            "$.schema_version",
961        ));
962    };
963    if version != i128::from(SOLVE_JSON_SCHEMA_VERSION_V1) {
964        return Err(protocol_error(
965            SolveErrorCodeV1::UnsupportedSchemaVersion,
966            format!(
967                "unsupported schema_version {version}; expected {SOLVE_JSON_SCHEMA_VERSION_V1}"
968            ),
969            "$.schema_version",
970        ));
971    }
972    Ok(())
973}
974
975fn validate_projectile(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
976    let path = "$.projectile";
977    let object = require_object(value, path)?;
978    validate_members(
979        object,
980        path,
981        &[
982            "mass_kg",
983            "diameter_m",
984            "length_m",
985            "drag_model",
986            "ballistic_coefficient",
987        ],
988        &[
989            "mass_kg",
990            "diameter_m",
991            "drag_model",
992            "ballistic_coefficient",
993        ],
994    )?;
995    validate_required_numbers(
996        object,
997        path,
998        &["mass_kg", "diameter_m", "ballistic_coefficient"],
999    )?;
1000    validate_optional_number(object, path, "length_m")?;
1001    validate_string_enum(
1002        required_value(object, "drag_model", path)?,
1003        "$.projectile.drag_model",
1004        &["G1", "G6", "G7", "G8"],
1005    )
1006}
1007
1008fn validate_rifle(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1009    let path = "$.rifle";
1010    let object = require_object(value, path)?;
1011    validate_members(
1012        object,
1013        path,
1014        &[
1015            "muzzle_velocity_mps",
1016            "sight_height_m",
1017            "muzzle_height_m",
1018            "twist_rate_m_per_turn",
1019            "twist_direction",
1020        ],
1021        &["muzzle_velocity_mps"],
1022    )?;
1023    validate_required_numbers(object, path, &["muzzle_velocity_mps"])?;
1024    validate_optional_numbers(
1025        object,
1026        path,
1027        &["sight_height_m", "muzzle_height_m", "twist_rate_m_per_turn"],
1028    )?;
1029    if let Some(direction) = object.get("twist_direction") {
1030        validate_string_enum(direction, "$.rifle.twist_direction", &["left", "right"])?;
1031    }
1032    Ok(())
1033}
1034
1035fn validate_shot(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1036    let path = "$.shot";
1037    let object = require_object(value, path)?;
1038    validate_members(
1039        object,
1040        path,
1041        &[
1042            "max_range_m",
1043            "zero_distance_m",
1044            "muzzle_angle_rad",
1045            "aim_azimuth_rad",
1046            "shot_azimuth_rad",
1047            "shooting_angle_rad",
1048            "cant_angle_rad",
1049            "target_height_m",
1050            "ground_threshold_m",
1051        ],
1052        &["max_range_m"],
1053    )?;
1054    validate_required_numbers(object, path, &["max_range_m"])?;
1055    validate_optional_number(object, path, "zero_distance_m")?;
1056    validate_optional_number(object, path, "muzzle_angle_rad")?;
1057    validate_optional_numbers(
1058        object,
1059        path,
1060        &[
1061            "aim_azimuth_rad",
1062            "shot_azimuth_rad",
1063            "shooting_angle_rad",
1064            "cant_angle_rad",
1065            "target_height_m",
1066            "ground_threshold_m",
1067        ],
1068    )
1069}
1070
1071fn validate_atmosphere(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1072    let path = "$.atmosphere";
1073    let object = require_object(value, path)?;
1074    validate_members(
1075        object,
1076        path,
1077        &[
1078            "altitude_m",
1079            "temperature_k",
1080            "pressure_pa",
1081            "relative_humidity",
1082            "latitude_rad",
1083        ],
1084        &[],
1085    )?;
1086    validate_optional_numbers(
1087        object,
1088        path,
1089        &[
1090            "altitude_m",
1091            "temperature_k",
1092            "pressure_pa",
1093            "relative_humidity",
1094            "latitude_rad",
1095        ],
1096    )
1097}
1098
1099fn validate_wind(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1100    let path = "$.wind";
1101    let object = require_object(value, path)?;
1102    validate_members(
1103        object,
1104        path,
1105        &[
1106            "speed_mps",
1107            "direction_from_rad",
1108            "vertical_speed_mps",
1109            "segments",
1110        ],
1111        &[],
1112    )?;
1113    for field in ["speed_mps", "direction_from_rad", "vertical_speed_mps"] {
1114        validate_optional_number(object, path, field)?;
1115    }
1116
1117    if let Some(segments) = object.get("segments") {
1118        let Some(segments) = segments.as_array() else {
1119            return Err(protocol_error(
1120                SolveErrorCodeV1::InvalidValue,
1121                "segments must be an array",
1122                "$.wind.segments",
1123            ));
1124        };
1125        for (index, segment) in segments.iter().enumerate() {
1126            let segment_path = format!("$.wind.segments[{index}]");
1127            let segment = require_object(segment, &segment_path)?;
1128            validate_members(
1129                segment,
1130                &segment_path,
1131                &[
1132                    "until_distance_m",
1133                    "speed_mps",
1134                    "direction_from_rad",
1135                    "vertical_speed_mps",
1136                ],
1137                &["until_distance_m", "speed_mps", "direction_from_rad"],
1138            )?;
1139            validate_required_numbers(
1140                segment,
1141                &segment_path,
1142                &["until_distance_m", "speed_mps", "direction_from_rad"],
1143            )?;
1144            validate_optional_number(segment, &segment_path, "vertical_speed_mps")?;
1145        }
1146    }
1147    Ok(())
1148}
1149
1150fn validate_solver(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1151    let path = "$.solver";
1152    let object = require_object(value, path)?;
1153    validate_members(object, path, &["method", "time_step_s"], &[])?;
1154    validate_optional_number(object, path, "time_step_s")?;
1155    if let Some(method) = object.get("method") {
1156        validate_string_enum(method, "$.solver.method", &["euler", "rk4", "rk45"])?;
1157    }
1158    Ok(())
1159}
1160
1161fn validate_effects(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1162    let path = "$.effects";
1163    let object = require_object(value, path)?;
1164    validate_members(
1165        object,
1166        path,
1167        &["magnus", "coriolis", "enhanced_spin_drift"],
1168        &[],
1169    )?;
1170    validate_optional_booleans(object, path, &["magnus", "coriolis", "enhanced_spin_drift"])?;
1171
1172    if object.get("magnus").and_then(Value::as_bool) == Some(true)
1173        && object.get("enhanced_spin_drift").and_then(Value::as_bool) == Some(true)
1174    {
1175        return Err(protocol_error(
1176            SolveErrorCodeV1::ConflictingFields,
1177            "magnus and enhanced_spin_drift cannot both be enabled",
1178            "$.effects",
1179        ));
1180    }
1181
1182    Ok(())
1183}
1184
1185fn validate_sampling(value: &Value) -> Result<(), SolveErrorEnvelopeV1> {
1186    let path = "$.sampling";
1187    let object = require_object(value, path)?;
1188    validate_members(object, path, &["interval_m"], &[])?;
1189    validate_optional_number(object, path, "interval_m")
1190}
1191
1192fn require_object<'a>(
1193    value: &'a Value,
1194    path: &str,
1195) -> Result<&'a Map<String, Value>, SolveErrorEnvelopeV1> {
1196    value
1197        .as_object()
1198        .ok_or_else(|| protocol_error(SolveErrorCodeV1::InvalidValue, "expected an object", path))
1199}
1200
1201fn required_value<'a>(
1202    object: &'a Map<String, Value>,
1203    field: &str,
1204    parent_path: &str,
1205) -> Result<&'a Value, SolveErrorEnvelopeV1> {
1206    object.get(field).ok_or_else(|| {
1207        protocol_error(
1208            SolveErrorCodeV1::MissingField,
1209            format!("missing required field `{field}`"),
1210            child_path(parent_path, field),
1211        )
1212    })
1213}
1214
1215fn validate_members(
1216    object: &Map<String, Value>,
1217    path: &str,
1218    allowed: &[&str],
1219    required: &[&str],
1220) -> Result<(), SolveErrorEnvelopeV1> {
1221    if let Some(field) = object
1222        .keys()
1223        .find(|field| !allowed.contains(&field.as_str()))
1224    {
1225        return Err(protocol_error(
1226            SolveErrorCodeV1::UnknownField,
1227            format!("unknown field `{field}`"),
1228            child_path(path, field),
1229        ));
1230    }
1231
1232    if let Some(field) = required.iter().find(|field| !object.contains_key(**field)) {
1233        return Err(protocol_error(
1234            SolveErrorCodeV1::MissingField,
1235            format!("missing required field `{field}`"),
1236            child_path(path, field),
1237        ));
1238    }
1239    Ok(())
1240}
1241
1242fn validate_string_enum(
1243    value: &Value,
1244    path: &str,
1245    allowed: &[&str],
1246) -> Result<(), SolveErrorEnvelopeV1> {
1247    let Some(value) = value.as_str() else {
1248        return Err(protocol_error(
1249            SolveErrorCodeV1::InvalidValue,
1250            "expected a string enum value",
1251            path,
1252        ));
1253    };
1254    if !allowed.contains(&value) {
1255        return Err(protocol_error(
1256            SolveErrorCodeV1::InvalidValue,
1257            format!(
1258                "invalid value `{value}`; expected one of {}",
1259                allowed.join(", ")
1260            ),
1261            path,
1262        ));
1263    }
1264    Ok(())
1265}
1266
1267fn validate_required_numbers(
1268    object: &Map<String, Value>,
1269    parent_path: &str,
1270    fields: &[&str],
1271) -> Result<(), SolveErrorEnvelopeV1> {
1272    for field in fields {
1273        let value = required_value(object, field, parent_path)?;
1274        validate_number(value, &child_path(parent_path, field))?;
1275    }
1276    Ok(())
1277}
1278
1279fn validate_optional_numbers(
1280    object: &Map<String, Value>,
1281    parent_path: &str,
1282    fields: &[&str],
1283) -> Result<(), SolveErrorEnvelopeV1> {
1284    for field in fields {
1285        validate_optional_number(object, parent_path, field)?;
1286    }
1287    Ok(())
1288}
1289
1290fn validate_optional_number(
1291    object: &Map<String, Value>,
1292    parent_path: &str,
1293    field: &str,
1294) -> Result<(), SolveErrorEnvelopeV1> {
1295    if let Some(value) = object.get(field) {
1296        validate_number(value, &child_path(parent_path, field))?;
1297    }
1298    Ok(())
1299}
1300
1301fn validate_number(value: &Value, path: &str) -> Result<(), SolveErrorEnvelopeV1> {
1302    if value.is_number() {
1303        Ok(())
1304    } else {
1305        Err(protocol_error(
1306            SolveErrorCodeV1::InvalidValue,
1307            "expected a number",
1308            path,
1309        ))
1310    }
1311}
1312
1313fn validate_optional_booleans(
1314    object: &Map<String, Value>,
1315    parent_path: &str,
1316    fields: &[&str],
1317) -> Result<(), SolveErrorEnvelopeV1> {
1318    for field in fields {
1319        if let Some(value) = object.get(*field) {
1320            if !value.is_boolean() {
1321                return Err(protocol_error(
1322                    SolveErrorCodeV1::InvalidValue,
1323                    "expected a boolean",
1324                    child_path(parent_path, field),
1325                ));
1326            }
1327        }
1328    }
1329    Ok(())
1330}
1331
1332fn child_path(parent: &str, field: &str) -> String {
1333    format!("{parent}.{field}")
1334}