1#[cfg(feature = "online")]
8use crate::constants::GRAMS_PER_GRAIN;
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12#[derive(Debug, Clone, Serialize)]
14pub struct TrajectoryRequest {
15 pub bc_value: f64,
17 pub bc_type: String,
19 pub bullet_mass: f64,
21 pub muzzle_velocity: f64,
23 pub target_distance: f64,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub zero_range: Option<f64>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub wind_speed: Option<f64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub wind_angle: Option<f64>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub temperature: Option<f64>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub pressure: Option<f64>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub humidity: Option<f64>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub altitude: Option<f64>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub latitude: Option<f64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub longitude: Option<f64>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub shot_direction: Option<f64>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub shooting_angle: Option<f64>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub twist_rate: Option<f64>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub bullet_diameter: Option<f64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub bullet_length: Option<f64>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub ground_threshold: Option<f64>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub enable_weather_zones: Option<bool>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub enable_3d_weather: Option<bool>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub wind_shear_model: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub weather_zone_interpolation: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
85 pub sample_interval: Option<f64>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
90pub struct TrajectoryResponse {
91 pub trajectory: Vec<ApiTrajectoryPoint>,
93 pub zero_angle: f64,
95 pub time_of_flight: f64,
97 #[serde(default)]
99 pub bc_confidence: Option<f64>,
100 #[serde(default)]
102 pub ml_corrections_applied: Option<Vec<String>>,
103 #[serde(default)]
105 pub max_ordinate: Option<f64>,
106 #[serde(default)]
108 pub impact_velocity: Option<f64>,
109 #[serde(default)]
111 pub impact_energy: Option<f64>,
112}
113
114#[derive(Debug, Clone, Deserialize)]
116pub struct ApiTrajectoryPoint {
117 pub range: f64,
119 pub drop: f64,
121 pub drift: f64,
123 pub velocity: f64,
125 pub energy: f64,
127 pub time: f64,
129}
130
131#[derive(Debug, Clone, Serialize)]
133pub struct TrueVelocityRequest {
134 pub measured_drop_mil: f64,
136 pub range_yd: f64,
138 pub bc: f64,
140 pub drag_model: String,
142 pub weight_gr: f64,
144 pub caliber: f64,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub zero_range_yd: Option<f64>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub chrono_velocity_fps: Option<f64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub altitude_ft: Option<f64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub temperature_f: Option<f64>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub pressure_inhg: Option<f64>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub humidity: Option<f64>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub sight_height_in: Option<f64>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub use_bc_enhancement: Option<bool>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
174pub struct TrueVelocityResponse {
175 pub effective_velocity_fps: f64,
177 #[serde(default)]
179 pub velocity_adjustment_fps: Option<f64>,
180 #[serde(default)]
182 pub adjustment_percent: Option<f64>,
183 pub confidence: String,
185 pub iterations: i32,
187 pub final_error_mil: f64,
189 pub calculated_drop_mil: f64,
191}
192
193#[derive(Debug)]
195pub enum ApiError {
196 NetworkError(String),
198 Timeout,
200 InvalidResponse(String),
202 ServerError(u16, String),
204 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 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
237pub 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 pub fn new(base_url: &str, timeout_secs: u64) -> Self {
252 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 pub fn with_token(mut self, token: Option<String>) -> Self {
264 self.token = token;
265 self
266 }
267
268 #[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 #[cfg(feature = "online")]
285 pub fn calculate_trajectory(
286 &self,
287 request: &TrajectoryRequest,
288 ) -> Result<TrajectoryResponse, ApiError> {
289 let url = format!("{}/v1/calculate", self.base_url);
291
292 let velocity_fps = request.muzzle_velocity / 0.3048; let mass_grains = request.bullet_mass / GRAMS_PER_GRAIN; let distance_yards = request.target_distance / 0.9144; 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 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; 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; req = req.query("temperature", format!("{:.1}", temp_f));
328 }
329 if let Some(pressure) = request.pressure {
330 let pressure_inhg = pressure / 33.8639; 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; 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; req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
358 }
359 if let Some(threshold) = request.ground_threshold {
360 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 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 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 let api_response: serde_json::Value = serde_json::from_str(&body)
408 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
409
410 self.convert_api_response(&api_response)
412 }
413
414 #[cfg(feature = "online")]
416 fn extract_value(val: &serde_json::Value) -> Option<f64> {
417 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 let results = api_response.get("results");
427
428 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 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, drop: drop_inches * 0.0254, drift: drift_inches * 0.0254, velocity: velocity_fps * 0.3048, energy: energy_ftlbs * 1.35582, time,
462 })
463 })
464 .collect();
465
466 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 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); let impact_velocity = results
497 .and_then(|r| r.get("final_velocity"))
498 .and_then(Self::extract_value)
499 .map(|v| v * 0.3048); let impact_energy = results
502 .and_then(|r| r.get("final_energy"))
503 .and_then(Self::extract_value)
504 .map(|e| e * 1.35582); 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 #[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 #[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 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 #[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 #[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#[cfg(feature = "online")]
655fn map_ureq_error(e: ureq::Error) -> ApiError {
656 match e {
657 ureq::Error::Timeout(_) => ApiError::Timeout,
659 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 => ApiError::NetworkError(other.to_string()),
669 }
670}
671
672#[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 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) .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}