Skip to main content

ballistics_engine/
api_client.rs

1//! HTTP client for Flask API communication
2//!
3//! This module provides an HTTP client for routing trajectory calculations
4//! through the Flask API instead of local computation, giving CLI users
5//! access to ML-enhanced predictions.
6
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10/// Request structure for trajectory calculation via Flask API
11#[derive(Debug, Clone, Serialize)]
12pub struct TrajectoryRequest {
13    /// Ballistic coefficient value
14    pub bc_value: f64,
15    /// BC type: "G1" or "G7"
16    pub bc_type: String,
17    /// Bullet mass in grams
18    pub bullet_mass: f64,
19    /// Muzzle velocity in m/s
20    pub muzzle_velocity: f64,
21    /// Target distance in meters
22    pub target_distance: f64,
23    /// Zero range in meters (optional)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub zero_range: Option<f64>,
26    /// Wind speed in m/s (optional)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub wind_speed: Option<f64>,
29    /// Wind angle in degrees (optional)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub wind_angle: Option<f64>,
32    /// Temperature in Celsius (optional)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub temperature: Option<f64>,
35    /// Pressure in hPa/mbar (optional)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub pressure: Option<f64>,
38    /// Humidity percentage 0-100 (optional)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub humidity: Option<f64>,
41    /// Altitude in meters (optional)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub altitude: Option<f64>,
44    /// Latitude for Coriolis calculations (optional)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub latitude: Option<f64>,
47    /// Longitude for weather zones (optional)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub longitude: Option<f64>,
50    /// Shot direction/azimuth in degrees (optional, 0=North, 90=East)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub shot_direction: Option<f64>,
53    /// Shooting angle in degrees (optional)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub shooting_angle: Option<f64>,
56    /// Barrel twist rate in inches per turn (optional)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub twist_rate: Option<f64>,
59    /// Bullet diameter in meters (optional)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub bullet_diameter: Option<f64>,
62    /// Bullet length in meters (optional)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub bullet_length: Option<f64>,
65    /// Ground threshold in meters (optional, negative = ignore ground impact)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub ground_threshold: Option<f64>,
68    /// Enable weather zones (optional)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub enable_weather_zones: Option<bool>,
71    /// Enable 3D weather corrections (optional)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub enable_3d_weather: Option<bool>,
74    /// Wind shear model (optional: none, logarithmic, power_law, ekman_spiral)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub wind_shear_model: Option<String>,
77    /// Weather zone interpolation method (optional: linear, cubic, step)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub weather_zone_interpolation: Option<String>,
80    /// Sample/trajectory step interval in meters (optional)
81    /// Will be converted to yards for API as trajectory_step
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub sample_interval: Option<f64>,
84}
85
86/// Response structure from Flask API trajectory calculation
87#[derive(Debug, Clone, Deserialize)]
88pub struct TrajectoryResponse {
89    /// Array of trajectory points
90    pub trajectory: Vec<ApiTrajectoryPoint>,
91    /// Zero angle in radians
92    pub zero_angle: f64,
93    /// Total time of flight in seconds
94    pub time_of_flight: f64,
95    /// BC confidence score (0-1) if available
96    #[serde(default)]
97    pub bc_confidence: Option<f64>,
98    /// List of ML corrections applied
99    #[serde(default)]
100    pub ml_corrections_applied: Option<Vec<String>>,
101    /// Maximum ordinate (height) in meters
102    #[serde(default)]
103    pub max_ordinate: Option<f64>,
104    /// Impact velocity in m/s
105    #[serde(default)]
106    pub impact_velocity: Option<f64>,
107    /// Impact energy in Joules
108    #[serde(default)]
109    pub impact_energy: Option<f64>,
110}
111
112/// A single point in the trajectory from API response
113#[derive(Debug, Clone, Deserialize)]
114pub struct ApiTrajectoryPoint {
115    /// Range/distance in meters
116    pub range: f64,
117    /// Drop below line of sight in meters (negative = below)
118    pub drop: f64,
119    /// Wind drift in meters
120    pub drift: f64,
121    /// Velocity at this point in m/s
122    pub velocity: f64,
123    /// Kinetic energy at this point in Joules
124    pub energy: f64,
125    /// Time of flight to this point in seconds
126    pub time: f64,
127}
128
129/// Request structure for velocity truing via Flask API
130#[derive(Debug, Clone, Serialize)]
131pub struct TrueVelocityRequest {
132    /// Measured drop in MILs at the target range
133    pub measured_drop_mil: f64,
134    /// Range at which drop was measured (yards)
135    pub range_yd: f64,
136    /// Ballistic coefficient
137    pub bc: f64,
138    /// Drag model ("G1" or "G7")
139    pub drag_model: String,
140    /// Bullet weight in grains
141    pub weight_gr: f64,
142    /// Bullet caliber/diameter in inches
143    pub caliber: f64,
144    /// Zero range in yards (default: 100)
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub zero_range_yd: Option<f64>,
147    /// Chronograph velocity in fps (optional, for comparison)
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub chrono_velocity_fps: Option<f64>,
150    /// Altitude in feet (default: 0)
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub altitude_ft: Option<f64>,
153    /// Temperature in Fahrenheit (default: 59)
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub temperature_f: Option<f64>,
156    /// Barometric pressure in inHg (default: 29.92)
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub pressure_inhg: Option<f64>,
159    /// Humidity percentage (default: 50)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub humidity: Option<f64>,
162    /// Sight height in inches (default: 2.0)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub sight_height_in: Option<f64>,
165    /// Enable BC enhancement (default: true)
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub use_bc_enhancement: Option<bool>,
168}
169
170/// Response structure from Flask API velocity truing
171#[derive(Debug, Clone, Deserialize)]
172pub struct TrueVelocityResponse {
173    /// Effective muzzle velocity in fps
174    pub effective_velocity_fps: f64,
175    /// Velocity adjustment from chrono (if chrono provided)
176    #[serde(default)]
177    pub velocity_adjustment_fps: Option<f64>,
178    /// Adjustment as percentage (if chrono provided)
179    #[serde(default)]
180    pub adjustment_percent: Option<f64>,
181    /// Confidence in the result ("high", "medium", "low")
182    pub confidence: String,
183    /// Number of iterations to converge
184    pub iterations: i32,
185    /// Final error in MILs after convergence
186    pub final_error_mil: f64,
187    /// Calculated drop at the effective velocity
188    pub calculated_drop_mil: f64,
189}
190
191/// Error types for API communication
192#[derive(Debug)]
193pub enum ApiError {
194    /// Network connectivity error
195    NetworkError(String),
196    /// Request timed out
197    Timeout,
198    /// Invalid or unparseable response
199    InvalidResponse(String),
200    /// HTTP error from server (status code, message)
201    ServerError(u16, String),
202    /// Request building error
203    RequestError(String),
204}
205
206impl std::fmt::Display for ApiError {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
210            ApiError::Timeout => write!(f, "Request timed out"),
211            ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
212            ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
213            ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
214        }
215    }
216}
217
218impl std::error::Error for ApiError {}
219
220/// HTTP client for Flask API communication
221pub struct ApiClient {
222    base_url: String,
223    timeout: Duration,
224}
225
226impl ApiClient {
227    /// Create a new API client
228    ///
229    /// # Arguments
230    /// * `base_url` - Base URL of the Flask API (e.g., "https://api.ballistics.7.62x51mm.sh")
231    /// * `timeout_secs` - Request timeout in seconds
232    pub fn new(base_url: &str, timeout_secs: u64) -> Self {
233        // Normalize URL by removing trailing slash
234        let base_url = base_url.trim_end_matches('/').to_string();
235
236        Self {
237            base_url,
238            timeout: Duration::from_secs(timeout_secs),
239        }
240    }
241
242    /// Calculate trajectory via Flask API
243    ///
244    /// # Arguments
245    /// * `request` - Trajectory calculation request parameters
246    ///
247    /// # Returns
248    /// * `Ok(TrajectoryResponse)` - Successful calculation with trajectory data
249    /// * `Err(ApiError)` - Error during API communication
250    #[cfg(feature = "online")]
251    pub fn calculate_trajectory(
252        &self,
253        request: &TrajectoryRequest,
254    ) -> Result<TrajectoryResponse, ApiError> {
255        // Flask API uses GET /v1/calculate with query parameters (imperial units)
256        let url = format!("{}/v1/calculate", self.base_url);
257
258        // Convert metric values to imperial for API
259        let velocity_fps = request.muzzle_velocity / 0.3048; // m/s to fps
260        let mass_grains = request.bullet_mass / 0.0647989; // grams to grains
261        let distance_yards = request.target_distance / 0.9144; // meters to yards
262
263        let mut req = ureq::get(&url)
264            .config()
265            .timeout_global(Some(self.timeout))
266            .build()
267            .header("Accept", "application/json")
268            .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
269            .query("bc_value", request.bc_value.to_string())
270            .query("bc_type", &request.bc_type)
271            .query("bullet_mass", format!("{:.1}", mass_grains))
272            .query("muzzle_velocity", format!("{:.1}", velocity_fps))
273            .query("target_distance", format!("{:.1}", distance_yards));
274
275        // Add optional parameters
276        if let Some(zero_range) = request.zero_range {
277            let zero_yards = zero_range / 0.9144;
278            req = req.query("zero_distance", format!("{:.1}", zero_yards));
279        }
280        if let Some(wind_speed) = request.wind_speed {
281            let wind_mph = wind_speed * 2.23694; // m/s to mph
282            req = req.query("wind_speed", format!("{:.1}", wind_mph));
283        }
284        if let Some(wind_angle) = request.wind_angle {
285            req = req.query("wind_angle", format!("{:.1}", wind_angle));
286        }
287        if let Some(temp) = request.temperature {
288            let temp_f = temp * 9.0 / 5.0 + 32.0; // Celsius to Fahrenheit
289            req = req.query("temperature", format!("{:.1}", temp_f));
290        }
291        if let Some(pressure) = request.pressure {
292            let pressure_inhg = pressure / 33.8639; // hPa to inHg
293            req = req.query("pressure", format!("{:.2}", pressure_inhg));
294        }
295        if let Some(humidity) = request.humidity {
296            req = req.query("humidity", format!("{:.1}", humidity));
297        }
298        if let Some(altitude) = request.altitude {
299            let altitude_ft = altitude / 0.3048; // meters to feet
300            req = req.query("altitude", format!("{:.1}", altitude_ft));
301        }
302        if let Some(shooting_angle) = request.shooting_angle {
303            req = req.query("shooting_angle", format!("{:.1}", shooting_angle));
304        }
305        if let Some(latitude) = request.latitude {
306            req = req.query("latitude", format!("{:.2}", latitude));
307        }
308        if let Some(longitude) = request.longitude {
309            req = req.query("longitude", format!("{:.2}", longitude));
310        }
311        if let Some(shot_direction) = request.shot_direction {
312            req = req.query("shot_direction", format!("{:.1}", shot_direction));
313        }
314        if let Some(twist_rate) = request.twist_rate {
315            req = req.query("twist_rate", format!("{:.1}", twist_rate));
316        }
317        if let Some(diameter) = request.bullet_diameter {
318            let diameter_in = diameter / 0.0254; // meters to inches
319            req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
320        }
321        if let Some(threshold) = request.ground_threshold {
322            // Ground threshold is in meters. --ignore-ground-impact sets f64::NEG_INFINITY, which
323            // format!("{:.1}") would render as the literal "-inf" (rejected by most server-side
324            // numeric validators, silently dropping the ignore-ground intent); send a large finite
325            // negative sentinel for THAT case only. Finite values pass through. Any other
326            // non-finite value (NaN or +Inf) is invalid input, not an ignore-ground request, so
327            // omit the parameter rather than silently mapping it to the sentinel.
328            if threshold.is_finite() {
329                req = req.query("ground_threshold", format!("{:.1}", threshold));
330            } else if threshold == f64::NEG_INFINITY {
331                req = req.query("ground_threshold", "-1000000000.0");
332            }
333        }
334        if let Some(enable) = request.enable_weather_zones {
335            req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
336        }
337        if let Some(enable) = request.enable_3d_weather {
338            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
339        }
340        if let Some(ref model) = request.wind_shear_model {
341            req = req.query("wind_shear_model", model);
342        }
343        if let Some(ref method) = request.weather_zone_interpolation {
344            req = req.query("weather_zone_interpolation", method);
345        }
346        if let Some(sample_interval) = request.sample_interval {
347            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
348            let step_yards = sample_interval / 0.9144;
349            req = req.query("trajectory_step", format!("{:.4}", step_yards));
350        }
351
352        let mut response = req.call().map_err(map_ureq_error)?;
353
354        // In ureq 3.x, 4xx/5xx responses are no longer returned as Err by default;
355        // .call() yields Ok(response) for any status. Inspect the status ourselves and
356        // map non-2xx to ServerError(code, body) to preserve the previous behavior.
357        let status = response.status();
358        if !status.is_success() {
359            let body = response.body_mut().read_to_string().unwrap_or_default();
360            return Err(ApiError::ServerError(status.as_u16(), body));
361        }
362
363        let body = response
364            .body_mut()
365            .read_to_string()
366            .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
367
368        // Parse the Flask API response and convert to our format
369        let api_response: serde_json::Value = serde_json::from_str(&body)
370            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
371
372        // Convert Flask API response to our TrajectoryResponse format
373        self.convert_api_response(&api_response)
374    }
375
376    /// Helper to extract a value from a nested {value: x, unit: y} structure or plain number
377    #[cfg(feature = "online")]
378    fn extract_value(val: &serde_json::Value) -> Option<f64> {
379        // Try nested {value: x} first, then plain number
380        val.get("value")
381            .and_then(|v| v.as_f64())
382            .or_else(|| val.as_f64())
383    }
384
385    #[cfg(feature = "online")]
386    fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
387        // Get results object
388        let results = api_response.get("results");
389
390        // Extract trajectory points from Flask API response
391        // The Flask API returns trajectory in "trajectory" array with nested value objects
392        let trajectory_array = api_response.get("trajectory")
393            .and_then(|t| t.as_array())
394            .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
395
396        let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
397            .iter()
398            .filter_map(|point| {
399                // Flask API returns nested {value: x, unit: y} objects in imperial units
400                let range_yards = point.get("distance")
401                    .and_then(Self::extract_value)?;
402                let drop_inches = point.get("drop")
403                    .and_then(Self::extract_value)
404                    .unwrap_or(0.0);
405                let drift_inches = point.get("wind_drift")
406                    .and_then(Self::extract_value)
407                    .unwrap_or(0.0);
408                let velocity_fps = point.get("velocity")
409                    .and_then(Self::extract_value)?;
410                let energy_ftlbs = point.get("energy")
411                    .and_then(Self::extract_value)
412                    .unwrap_or(0.0);
413                let time = point.get("time")
414                    .and_then(Self::extract_value)
415                    .unwrap_or(0.0);
416
417                Some(ApiTrajectoryPoint {
418                    range: range_yards * 0.9144,        // yards to meters
419                    drop: drop_inches * 0.0254,          // inches to meters
420                    drift: drift_inches * 0.0254,        // inches to meters
421                    velocity: velocity_fps * 0.3048,     // fps to m/s
422                    energy: energy_ftlbs * 1.35582,      // ft-lbs to Joules
423                    time,
424                })
425            })
426            .collect();
427
428        // Extract summary values from results object
429        let zero_angle = results
430            .and_then(|r| r.get("barrel_angle"))
431            .and_then(Self::extract_value)
432            .unwrap_or(0.0)
433            .to_radians();
434
435        let time_of_flight = results
436            .and_then(|r| r.get("time_of_flight"))
437            .and_then(Self::extract_value)
438            .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
439
440        let bc_confidence = api_response.get("bc_confidence")
441            .and_then(|v| v.as_f64());
442
443        let ml_corrections = api_response.get("ml_corrections_applied")
444            .or_else(|| api_response.get("corrections_applied"))
445            .and_then(|v| v.as_array())
446            .map(|arr| {
447                arr.iter()
448                    .filter_map(|v| v.as_str().map(String::from))
449                    .collect()
450            });
451
452        // max_height is directly a number in results
453        let max_ordinate = results
454            .and_then(|r| r.get("max_height"))
455            .and_then(|v| v.as_f64())
456            .map(|h| h * 0.0254); // inches to meters
457
458        let impact_velocity = results
459            .and_then(|r| r.get("final_velocity"))
460            .and_then(Self::extract_value)
461            .map(|v| v * 0.3048); // fps to m/s
462
463        let impact_energy = results
464            .and_then(|r| r.get("final_energy"))
465            .and_then(Self::extract_value)
466            .map(|e| e * 1.35582); // ft-lbs to Joules
467
468        Ok(TrajectoryResponse {
469            trajectory,
470            zero_angle,
471            time_of_flight,
472            bc_confidence,
473            ml_corrections_applied: ml_corrections,
474            max_ordinate,
475            impact_velocity,
476            impact_energy,
477        })
478    }
479
480    /// Check API health
481    #[cfg(feature = "online")]
482    pub fn health_check(&self) -> Result<bool, ApiError> {
483        let url = format!("{}/health", self.base_url);
484
485        let response = ureq::get(&url)
486            .config()
487            .timeout_global(Some(Duration::from_secs(5)))
488            .build()
489            .call()
490            .map_err(map_ureq_error)?;
491
492        Ok(response.status().as_u16() == 200)
493    }
494
495    /// Calculate true/effective muzzle velocity via Flask API
496    ///
497    /// # Arguments
498    /// * `request` - Velocity truing request parameters
499    ///
500    /// # Returns
501    /// * `Ok(TrueVelocityResponse)` - Successful calculation with effective velocity
502    /// * `Err(ApiError)` - Error during API communication
503    #[cfg(feature = "online")]
504    pub fn true_velocity(
505        &self,
506        request: &TrueVelocityRequest,
507    ) -> Result<TrueVelocityResponse, ApiError> {
508        let url = format!("{}/v1/true-velocity", self.base_url);
509
510        let body = serde_json::to_string(request)
511            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
512
513        let mut response = ureq::post(&url)
514            .config()
515            .timeout_global(Some(self.timeout))
516            .build()
517            .header("Content-Type", "application/json")
518            .header("Accept", "application/json")
519            .header("User-Agent", &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")))
520            .send(&body)
521            .map_err(map_ureq_error)?;
522
523        // In ureq 3.x, 4xx/5xx responses are returned as Ok; check status explicitly.
524        let status = response.status();
525        if !status.is_success() {
526            let body = response.body_mut().read_to_string().unwrap_or_default();
527            return Err(ApiError::ServerError(status.as_u16(), body));
528        }
529
530        let response_body = response
531            .body_mut()
532            .read_to_string()
533            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
534
535        serde_json::from_str(&response_body)
536            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
537    }
538}
539
540/// Map a ureq 3.x transport/protocol error to an `ApiError`.
541///
542/// Note: in ureq 3.x, HTTP 4xx/5xx responses are NOT returned as errors by default
543/// (`.call()`/`.send()` yield `Ok(response)` for any status), so the status is checked
544/// on the returned response rather than here. This helper only sees genuine
545/// transport-level failures.
546#[cfg(feature = "online")]
547fn map_ureq_error(e: ureq::Error) -> ApiError {
548    match e {
549        // Any configured timeout (global/connect/etc.) maps to Timeout.
550        ureq::Error::Timeout(_) => ApiError::Timeout,
551        // Socket/IO errors: distinguish timeouts surfaced as IO errors, otherwise network.
552        ureq::Error::Io(io_err) => {
553            if io_err.kind() == std::io::ErrorKind::TimedOut {
554                ApiError::Timeout
555            } else {
556                ApiError::NetworkError(io_err.to_string())
557            }
558        }
559        // Other transport-level failures (DNS, connect, proxy, redirects, TLS, etc.).
560        other => ApiError::NetworkError(other.to_string()),
561    }
562}
563
564/// Builder for TrajectoryRequest
565#[derive(Default)]
566pub struct TrajectoryRequestBuilder {
567    bc_value: Option<f64>,
568    bc_type: Option<String>,
569    bullet_mass: Option<f64>,
570    muzzle_velocity: Option<f64>,
571    target_distance: Option<f64>,
572    zero_range: Option<f64>,
573    wind_speed: Option<f64>,
574    wind_angle: Option<f64>,
575    temperature: Option<f64>,
576    pressure: Option<f64>,
577    humidity: Option<f64>,
578    altitude: Option<f64>,
579    latitude: Option<f64>,
580    longitude: Option<f64>,
581    shot_direction: Option<f64>,
582    shooting_angle: Option<f64>,
583    twist_rate: Option<f64>,
584    bullet_diameter: Option<f64>,
585    bullet_length: Option<f64>,
586    ground_threshold: Option<f64>,
587    enable_weather_zones: Option<bool>,
588    enable_3d_weather: Option<bool>,
589    wind_shear_model: Option<String>,
590    weather_zone_interpolation: Option<String>,
591    sample_interval: Option<f64>,
592}
593
594impl TrajectoryRequestBuilder {
595    pub fn new() -> Self {
596        Self::default()
597    }
598
599    pub fn bc_value(mut self, value: f64) -> Self {
600        self.bc_value = Some(value);
601        self
602    }
603
604    pub fn bc_type(mut self, value: &str) -> Self {
605        self.bc_type = Some(value.to_string());
606        self
607    }
608
609    pub fn bullet_mass(mut self, value: f64) -> Self {
610        self.bullet_mass = Some(value);
611        self
612    }
613
614    pub fn muzzle_velocity(mut self, value: f64) -> Self {
615        self.muzzle_velocity = Some(value);
616        self
617    }
618
619    pub fn target_distance(mut self, value: f64) -> Self {
620        self.target_distance = Some(value);
621        self
622    }
623
624    pub fn zero_range(mut self, value: f64) -> Self {
625        self.zero_range = Some(value);
626        self
627    }
628
629    pub fn wind_speed(mut self, value: f64) -> Self {
630        self.wind_speed = Some(value);
631        self
632    }
633
634    pub fn wind_angle(mut self, value: f64) -> Self {
635        self.wind_angle = Some(value);
636        self
637    }
638
639    pub fn temperature(mut self, value: f64) -> Self {
640        self.temperature = Some(value);
641        self
642    }
643
644    pub fn pressure(mut self, value: f64) -> Self {
645        self.pressure = Some(value);
646        self
647    }
648
649    pub fn humidity(mut self, value: f64) -> Self {
650        self.humidity = Some(value);
651        self
652    }
653
654    pub fn altitude(mut self, value: f64) -> Self {
655        self.altitude = Some(value);
656        self
657    }
658
659    pub fn latitude(mut self, value: f64) -> Self {
660        self.latitude = Some(value);
661        self
662    }
663
664    pub fn longitude(mut self, value: f64) -> Self {
665        self.longitude = Some(value);
666        self
667    }
668
669    pub fn shot_direction(mut self, value: f64) -> Self {
670        self.shot_direction = Some(value);
671        self
672    }
673
674    pub fn shooting_angle(mut self, value: f64) -> Self {
675        self.shooting_angle = Some(value);
676        self
677    }
678
679    pub fn twist_rate(mut self, value: f64) -> Self {
680        self.twist_rate = Some(value);
681        self
682    }
683
684    pub fn bullet_diameter(mut self, value: f64) -> Self {
685        self.bullet_diameter = Some(value);
686        self
687    }
688
689    pub fn bullet_length(mut self, value: f64) -> Self {
690        self.bullet_length = Some(value);
691        self
692    }
693
694    pub fn ground_threshold(mut self, value: f64) -> Self {
695        self.ground_threshold = Some(value);
696        self
697    }
698
699    pub fn enable_weather_zones(mut self, value: bool) -> Self {
700        self.enable_weather_zones = Some(value);
701        self
702    }
703
704    pub fn enable_3d_weather(mut self, value: bool) -> Self {
705        self.enable_3d_weather = Some(value);
706        self
707    }
708
709    pub fn wind_shear_model(mut self, value: &str) -> Self {
710        self.wind_shear_model = Some(value.to_string());
711        self
712    }
713
714    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
715        self.weather_zone_interpolation = Some(value.to_string());
716        self
717    }
718
719    pub fn sample_interval(mut self, value: f64) -> Self {
720        self.sample_interval = Some(value);
721        self
722    }
723
724    /// Build the TrajectoryRequest
725    ///
726    /// # Returns
727    /// * `Ok(TrajectoryRequest)` - Valid request
728    /// * `Err(String)` - Missing required fields
729    pub fn build(self) -> Result<TrajectoryRequest, String> {
730        let bc_value = self.bc_value.ok_or("bc_value is required")?;
731        let bc_type = self.bc_type.ok_or("bc_type is required")?;
732        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
733        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
734        let target_distance = self.target_distance.ok_or("target_distance is required")?;
735
736        Ok(TrajectoryRequest {
737            bc_value,
738            bc_type,
739            bullet_mass,
740            muzzle_velocity,
741            target_distance,
742            zero_range: self.zero_range,
743            wind_speed: self.wind_speed,
744            wind_angle: self.wind_angle,
745            temperature: self.temperature,
746            pressure: self.pressure,
747            humidity: self.humidity,
748            altitude: self.altitude,
749            latitude: self.latitude,
750            longitude: self.longitude,
751            shot_direction: self.shot_direction,
752            shooting_angle: self.shooting_angle,
753            twist_rate: self.twist_rate,
754            bullet_diameter: self.bullet_diameter,
755            bullet_length: self.bullet_length,
756            ground_threshold: self.ground_threshold,
757            enable_weather_zones: self.enable_weather_zones,
758            enable_3d_weather: self.enable_3d_weather,
759            wind_shear_model: self.wind_shear_model,
760            weather_zone_interpolation: self.weather_zone_interpolation,
761            sample_interval: self.sample_interval,
762        })
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    #[test]
771    fn test_request_builder_required_fields() {
772        let result = TrajectoryRequestBuilder::new()
773            .bc_value(0.238)
774            .bc_type("G7")
775            .bullet_mass(9.07) // 140gr in grams
776            .muzzle_velocity(860.0)
777            .target_distance(1000.0)
778            .build();
779
780        assert!(result.is_ok());
781        let request = result.unwrap();
782        assert_eq!(request.bc_value, 0.238);
783        assert_eq!(request.bc_type, "G7");
784    }
785
786    #[test]
787    fn test_request_builder_missing_fields() {
788        let result = TrajectoryRequestBuilder::new()
789            .bc_value(0.238)
790            .build();
791
792        assert!(result.is_err());
793    }
794
795    #[test]
796    fn test_request_builder_all_optional_fields() {
797        let result = TrajectoryRequestBuilder::new()
798            .bc_value(0.238)
799            .bc_type("G7")
800            .bullet_mass(9.07)
801            .muzzle_velocity(860.0)
802            .target_distance(1000.0)
803            .zero_range(100.0)
804            .wind_speed(5.0)
805            .wind_angle(90.0)
806            .temperature(15.0)
807            .pressure(1013.25)
808            .humidity(50.0)
809            .altitude(500.0)
810            .latitude(45.0)
811            .shooting_angle(0.0)
812            .twist_rate(10.0)
813            .bullet_diameter(0.00671)
814            .bullet_length(0.035)
815            .build();
816
817        assert!(result.is_ok());
818        let request = result.unwrap();
819        assert_eq!(request.zero_range, Some(100.0));
820        assert_eq!(request.wind_speed, Some(5.0));
821        assert_eq!(request.latitude, Some(45.0));
822    }
823
824    #[test]
825    fn test_api_client_url_normalization() {
826        let client1 = ApiClient::new("https://api.example.com/", 10);
827        assert_eq!(client1.base_url, "https://api.example.com");
828
829        let client2 = ApiClient::new("https://api.example.com", 10);
830        assert_eq!(client2.base_url, "https://api.example.com");
831    }
832
833    #[test]
834    fn test_api_error_display() {
835        assert_eq!(
836            format!("{}", ApiError::NetworkError("connection refused".to_string())),
837            "Network error: connection refused"
838        );
839        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
840        assert_eq!(
841            format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
842            "Server error 500: Internal error"
843        );
844    }
845}