1use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10#[derive(Debug, Clone, Serialize)]
12pub struct TrajectoryRequest {
13 pub bc_value: f64,
15 pub bc_type: String,
17 pub bullet_mass: f64,
19 pub muzzle_velocity: f64,
21 pub target_distance: f64,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub zero_range: Option<f64>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub wind_speed: Option<f64>,
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub wind_angle: Option<f64>,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub temperature: Option<f64>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub pressure: Option<f64>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub humidity: Option<f64>,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub altitude: Option<f64>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub latitude: Option<f64>,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub longitude: Option<f64>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub shot_direction: Option<f64>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub shooting_angle: Option<f64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub twist_rate: Option<f64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub bullet_diameter: Option<f64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub bullet_length: Option<f64>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub ground_threshold: Option<f64>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub enable_weather_zones: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub enable_3d_weather: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub wind_shear_model: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub weather_zone_interpolation: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize)]
84pub struct TrajectoryResponse {
85 pub trajectory: Vec<ApiTrajectoryPoint>,
87 pub zero_angle: f64,
89 pub time_of_flight: f64,
91 #[serde(default)]
93 pub bc_confidence: Option<f64>,
94 #[serde(default)]
96 pub ml_corrections_applied: Option<Vec<String>>,
97 #[serde(default)]
99 pub max_ordinate: Option<f64>,
100 #[serde(default)]
102 pub impact_velocity: Option<f64>,
103 #[serde(default)]
105 pub impact_energy: Option<f64>,
106}
107
108#[derive(Debug, Clone, Deserialize)]
110pub struct ApiTrajectoryPoint {
111 pub range: f64,
113 pub drop: f64,
115 pub drift: f64,
117 pub velocity: f64,
119 pub energy: f64,
121 pub time: f64,
123}
124
125#[derive(Debug)]
127pub enum ApiError {
128 NetworkError(String),
130 Timeout,
132 InvalidResponse(String),
134 ServerError(u16, String),
136 RequestError(String),
138}
139
140impl std::fmt::Display for ApiError {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 match self {
143 ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
144 ApiError::Timeout => write!(f, "Request timed out"),
145 ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
146 ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
147 ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
148 }
149 }
150}
151
152impl std::error::Error for ApiError {}
153
154pub struct ApiClient {
156 base_url: String,
157 timeout: Duration,
158}
159
160impl ApiClient {
161 pub fn new(base_url: &str, timeout_secs: u64) -> Self {
167 let base_url = base_url.trim_end_matches('/').to_string();
169
170 Self {
171 base_url,
172 timeout: Duration::from_secs(timeout_secs),
173 }
174 }
175
176 #[cfg(feature = "online")]
185 pub fn calculate_trajectory(
186 &self,
187 request: &TrajectoryRequest,
188 ) -> Result<TrajectoryResponse, ApiError> {
189 let url = format!("{}/v1/calculate", self.base_url);
191
192 let velocity_fps = request.muzzle_velocity / 0.3048; let mass_grains = request.bullet_mass / 0.0647989; let distance_yards = request.target_distance / 0.9144; let mut req = ureq::get(&url)
198 .set("Accept", "application/json")
199 .set("User-Agent", "ballistics-cli/0.13.28")
200 .timeout(self.timeout)
201 .query("bc_value", &request.bc_value.to_string())
202 .query("bc_type", &request.bc_type)
203 .query("bullet_mass", &format!("{:.1}", mass_grains))
204 .query("muzzle_velocity", &format!("{:.1}", velocity_fps))
205 .query("target_distance", &format!("{:.1}", distance_yards));
206
207 if let Some(zero_range) = request.zero_range {
209 let zero_yards = zero_range / 0.9144;
210 req = req.query("zero_distance", &format!("{:.1}", zero_yards));
211 }
212 if let Some(wind_speed) = request.wind_speed {
213 let wind_mph = wind_speed * 2.23694; req = req.query("wind_speed", &format!("{:.1}", wind_mph));
215 }
216 if let Some(wind_angle) = request.wind_angle {
217 req = req.query("wind_angle", &format!("{:.1}", wind_angle));
218 }
219 if let Some(temp) = request.temperature {
220 let temp_f = temp * 9.0 / 5.0 + 32.0; req = req.query("temperature", &format!("{:.1}", temp_f));
222 }
223 if let Some(pressure) = request.pressure {
224 let pressure_inhg = pressure / 33.8639; req = req.query("pressure", &format!("{:.2}", pressure_inhg));
226 }
227 if let Some(humidity) = request.humidity {
228 req = req.query("humidity", &format!("{:.1}", humidity));
229 }
230 if let Some(altitude) = request.altitude {
231 let altitude_ft = altitude / 0.3048; req = req.query("altitude", &format!("{:.1}", altitude_ft));
233 }
234 if let Some(shooting_angle) = request.shooting_angle {
235 req = req.query("shooting_angle", &format!("{:.1}", shooting_angle));
236 }
237 if let Some(latitude) = request.latitude {
238 req = req.query("latitude", &format!("{:.2}", latitude));
239 }
240 if let Some(longitude) = request.longitude {
241 req = req.query("longitude", &format!("{:.2}", longitude));
242 }
243 if let Some(shot_direction) = request.shot_direction {
244 req = req.query("shot_direction", &format!("{:.1}", shot_direction));
245 }
246 if let Some(twist_rate) = request.twist_rate {
247 req = req.query("twist_rate", &format!("{:.1}", twist_rate));
248 }
249 if let Some(diameter) = request.bullet_diameter {
250 let diameter_in = diameter / 0.0254; req = req.query("bullet_diameter", &format!("{:.3}", diameter_in));
252 }
253 if let Some(threshold) = request.ground_threshold {
254 req = req.query("ground_threshold", &format!("{:.1}", threshold));
257 }
258 if let Some(enable) = request.enable_weather_zones {
259 req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
260 }
261 if let Some(enable) = request.enable_3d_weather {
262 req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
263 }
264 if let Some(ref model) = request.wind_shear_model {
265 req = req.query("wind_shear_model", model);
266 }
267 if let Some(ref method) = request.weather_zone_interpolation {
268 req = req.query("weather_zone_interpolation", method);
269 }
270
271 let response = req.call().map_err(|e| match e {
272 ureq::Error::Status(code, response) => {
273 let body = response.into_string().unwrap_or_default();
274 ApiError::ServerError(code, body)
275 }
276 ureq::Error::Transport(transport) => {
277 let msg = transport.to_string();
279 if msg.contains("timed out") || msg.contains("timeout") {
280 ApiError::Timeout
281 } else {
282 ApiError::NetworkError(msg)
283 }
284 }
285 })?;
286
287 let body = response
288 .into_string()
289 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
290
291 let api_response: serde_json::Value = serde_json::from_str(&body)
293 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
294
295 self.convert_api_response(&api_response)
297 }
298
299 #[cfg(feature = "online")]
301 fn extract_value(val: &serde_json::Value) -> Option<f64> {
302 val.get("value")
304 .and_then(|v| v.as_f64())
305 .or_else(|| val.as_f64())
306 }
307
308 #[cfg(feature = "online")]
309 fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
310 let results = api_response.get("results");
312
313 let trajectory_array = api_response.get("trajectory")
316 .and_then(|t| t.as_array())
317 .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
318
319 let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
320 .iter()
321 .filter_map(|point| {
322 let range_yards = point.get("distance")
324 .and_then(Self::extract_value)?;
325 let drop_inches = point.get("drop")
326 .and_then(Self::extract_value)
327 .unwrap_or(0.0);
328 let drift_inches = point.get("wind_drift")
329 .and_then(Self::extract_value)
330 .unwrap_or(0.0);
331 let velocity_fps = point.get("velocity")
332 .and_then(Self::extract_value)?;
333 let energy_ftlbs = point.get("energy")
334 .and_then(Self::extract_value)
335 .unwrap_or(0.0);
336 let time = point.get("time")
337 .and_then(Self::extract_value)
338 .unwrap_or(0.0);
339
340 Some(ApiTrajectoryPoint {
341 range: range_yards * 0.9144, drop: drop_inches * 0.0254, drift: drift_inches * 0.0254, velocity: velocity_fps * 0.3048, energy: energy_ftlbs * 1.35582, time,
347 })
348 })
349 .collect();
350
351 let zero_angle = results
353 .and_then(|r| r.get("barrel_angle"))
354 .and_then(Self::extract_value)
355 .unwrap_or(0.0)
356 .to_radians();
357
358 let time_of_flight = results
359 .and_then(|r| r.get("time_of_flight"))
360 .and_then(Self::extract_value)
361 .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
362
363 let bc_confidence = api_response.get("bc_confidence")
364 .and_then(|v| v.as_f64());
365
366 let ml_corrections = api_response.get("ml_corrections_applied")
367 .or_else(|| api_response.get("corrections_applied"))
368 .and_then(|v| v.as_array())
369 .map(|arr| {
370 arr.iter()
371 .filter_map(|v| v.as_str().map(String::from))
372 .collect()
373 });
374
375 let max_ordinate = results
377 .and_then(|r| r.get("max_height"))
378 .and_then(|v| v.as_f64())
379 .map(|h| h * 0.0254); let impact_velocity = results
382 .and_then(|r| r.get("final_velocity"))
383 .and_then(Self::extract_value)
384 .map(|v| v * 0.3048); let impact_energy = results
387 .and_then(|r| r.get("final_energy"))
388 .and_then(Self::extract_value)
389 .map(|e| e * 1.35582); Ok(TrajectoryResponse {
392 trajectory,
393 zero_angle,
394 time_of_flight,
395 bc_confidence,
396 ml_corrections_applied: ml_corrections,
397 max_ordinate,
398 impact_velocity,
399 impact_energy,
400 })
401 }
402
403 #[cfg(feature = "online")]
405 pub fn health_check(&self) -> Result<bool, ApiError> {
406 let url = format!("{}/health", self.base_url);
407
408 let response = ureq::get(&url)
409 .timeout(Duration::from_secs(5))
410 .call()
411 .map_err(|e| match e {
412 ureq::Error::Status(code, response) => {
413 let body = response.into_string().unwrap_or_default();
414 ApiError::ServerError(code, body)
415 }
416 ureq::Error::Transport(transport) => {
417 let msg = transport.to_string();
418 if msg.contains("timed out") || msg.contains("timeout") {
419 ApiError::Timeout
420 } else {
421 ApiError::NetworkError(msg)
422 }
423 }
424 })?;
425
426 Ok(response.status() == 200)
427 }
428}
429
430#[derive(Default)]
432pub struct TrajectoryRequestBuilder {
433 bc_value: Option<f64>,
434 bc_type: Option<String>,
435 bullet_mass: Option<f64>,
436 muzzle_velocity: Option<f64>,
437 target_distance: Option<f64>,
438 zero_range: Option<f64>,
439 wind_speed: Option<f64>,
440 wind_angle: Option<f64>,
441 temperature: Option<f64>,
442 pressure: Option<f64>,
443 humidity: Option<f64>,
444 altitude: Option<f64>,
445 latitude: Option<f64>,
446 longitude: Option<f64>,
447 shot_direction: Option<f64>,
448 shooting_angle: Option<f64>,
449 twist_rate: Option<f64>,
450 bullet_diameter: Option<f64>,
451 bullet_length: Option<f64>,
452 ground_threshold: Option<f64>,
453 enable_weather_zones: Option<bool>,
454 enable_3d_weather: Option<bool>,
455 wind_shear_model: Option<String>,
456 weather_zone_interpolation: Option<String>,
457}
458
459impl TrajectoryRequestBuilder {
460 pub fn new() -> Self {
461 Self::default()
462 }
463
464 pub fn bc_value(mut self, value: f64) -> Self {
465 self.bc_value = Some(value);
466 self
467 }
468
469 pub fn bc_type(mut self, value: &str) -> Self {
470 self.bc_type = Some(value.to_string());
471 self
472 }
473
474 pub fn bullet_mass(mut self, value: f64) -> Self {
475 self.bullet_mass = Some(value);
476 self
477 }
478
479 pub fn muzzle_velocity(mut self, value: f64) -> Self {
480 self.muzzle_velocity = Some(value);
481 self
482 }
483
484 pub fn target_distance(mut self, value: f64) -> Self {
485 self.target_distance = Some(value);
486 self
487 }
488
489 pub fn zero_range(mut self, value: f64) -> Self {
490 self.zero_range = Some(value);
491 self
492 }
493
494 pub fn wind_speed(mut self, value: f64) -> Self {
495 self.wind_speed = Some(value);
496 self
497 }
498
499 pub fn wind_angle(mut self, value: f64) -> Self {
500 self.wind_angle = Some(value);
501 self
502 }
503
504 pub fn temperature(mut self, value: f64) -> Self {
505 self.temperature = Some(value);
506 self
507 }
508
509 pub fn pressure(mut self, value: f64) -> Self {
510 self.pressure = Some(value);
511 self
512 }
513
514 pub fn humidity(mut self, value: f64) -> Self {
515 self.humidity = Some(value);
516 self
517 }
518
519 pub fn altitude(mut self, value: f64) -> Self {
520 self.altitude = Some(value);
521 self
522 }
523
524 pub fn latitude(mut self, value: f64) -> Self {
525 self.latitude = Some(value);
526 self
527 }
528
529 pub fn longitude(mut self, value: f64) -> Self {
530 self.longitude = Some(value);
531 self
532 }
533
534 pub fn shot_direction(mut self, value: f64) -> Self {
535 self.shot_direction = Some(value);
536 self
537 }
538
539 pub fn shooting_angle(mut self, value: f64) -> Self {
540 self.shooting_angle = Some(value);
541 self
542 }
543
544 pub fn twist_rate(mut self, value: f64) -> Self {
545 self.twist_rate = Some(value);
546 self
547 }
548
549 pub fn bullet_diameter(mut self, value: f64) -> Self {
550 self.bullet_diameter = Some(value);
551 self
552 }
553
554 pub fn bullet_length(mut self, value: f64) -> Self {
555 self.bullet_length = Some(value);
556 self
557 }
558
559 pub fn ground_threshold(mut self, value: f64) -> Self {
560 self.ground_threshold = Some(value);
561 self
562 }
563
564 pub fn enable_weather_zones(mut self, value: bool) -> Self {
565 self.enable_weather_zones = Some(value);
566 self
567 }
568
569 pub fn enable_3d_weather(mut self, value: bool) -> Self {
570 self.enable_3d_weather = Some(value);
571 self
572 }
573
574 pub fn wind_shear_model(mut self, value: &str) -> Self {
575 self.wind_shear_model = Some(value.to_string());
576 self
577 }
578
579 pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
580 self.weather_zone_interpolation = Some(value.to_string());
581 self
582 }
583
584 pub fn build(self) -> Result<TrajectoryRequest, String> {
590 let bc_value = self.bc_value.ok_or("bc_value is required")?;
591 let bc_type = self.bc_type.ok_or("bc_type is required")?;
592 let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
593 let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
594 let target_distance = self.target_distance.ok_or("target_distance is required")?;
595
596 Ok(TrajectoryRequest {
597 bc_value,
598 bc_type,
599 bullet_mass,
600 muzzle_velocity,
601 target_distance,
602 zero_range: self.zero_range,
603 wind_speed: self.wind_speed,
604 wind_angle: self.wind_angle,
605 temperature: self.temperature,
606 pressure: self.pressure,
607 humidity: self.humidity,
608 altitude: self.altitude,
609 latitude: self.latitude,
610 longitude: self.longitude,
611 shot_direction: self.shot_direction,
612 shooting_angle: self.shooting_angle,
613 twist_rate: self.twist_rate,
614 bullet_diameter: self.bullet_diameter,
615 bullet_length: self.bullet_length,
616 ground_threshold: self.ground_threshold,
617 enable_weather_zones: self.enable_weather_zones,
618 enable_3d_weather: self.enable_3d_weather,
619 wind_shear_model: self.wind_shear_model,
620 weather_zone_interpolation: self.weather_zone_interpolation,
621 })
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn test_request_builder_required_fields() {
631 let result = TrajectoryRequestBuilder::new()
632 .bc_value(0.238)
633 .bc_type("G7")
634 .bullet_mass(9.07) .muzzle_velocity(860.0)
636 .target_distance(1000.0)
637 .build();
638
639 assert!(result.is_ok());
640 let request = result.unwrap();
641 assert_eq!(request.bc_value, 0.238);
642 assert_eq!(request.bc_type, "G7");
643 }
644
645 #[test]
646 fn test_request_builder_missing_fields() {
647 let result = TrajectoryRequestBuilder::new()
648 .bc_value(0.238)
649 .build();
650
651 assert!(result.is_err());
652 }
653
654 #[test]
655 fn test_request_builder_all_optional_fields() {
656 let result = TrajectoryRequestBuilder::new()
657 .bc_value(0.238)
658 .bc_type("G7")
659 .bullet_mass(9.07)
660 .muzzle_velocity(860.0)
661 .target_distance(1000.0)
662 .zero_range(100.0)
663 .wind_speed(5.0)
664 .wind_angle(90.0)
665 .temperature(15.0)
666 .pressure(1013.25)
667 .humidity(50.0)
668 .altitude(500.0)
669 .latitude(45.0)
670 .shooting_angle(0.0)
671 .twist_rate(10.0)
672 .bullet_diameter(0.00671)
673 .bullet_length(0.035)
674 .build();
675
676 assert!(result.is_ok());
677 let request = result.unwrap();
678 assert_eq!(request.zero_range, Some(100.0));
679 assert_eq!(request.wind_speed, Some(5.0));
680 assert_eq!(request.latitude, Some(45.0));
681 }
682
683 #[test]
684 fn test_api_client_url_normalization() {
685 let client1 = ApiClient::new("https://api.example.com/", 10);
686 assert_eq!(client1.base_url, "https://api.example.com");
687
688 let client2 = ApiClient::new("https://api.example.com", 10);
689 assert_eq!(client2.base_url, "https://api.example.com");
690 }
691
692 #[test]
693 fn test_api_error_display() {
694 assert_eq!(
695 format!("{}", ApiError::NetworkError("connection refused".to_string())),
696 "Network error: connection refused"
697 );
698 assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
699 assert_eq!(
700 format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
701 "Server error 500: Internal error"
702 );
703 }
704}