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