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