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(
303 "User-Agent",
304 &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
305 )
306 .query("bc_value", request.bc_value.to_string())
307 .query("bc_type", &request.bc_type)
308 .query("bullet_mass", format!("{:.1}", mass_grains))
309 .query("muzzle_velocity", format!("{:.1}", velocity_fps))
310 .query("target_distance", format!("{:.1}", distance_yards));
311
312 if let Some((name, value)) = self.auth_header() {
313 req = req.header(name, &value);
314 }
315
316 if let Some(zero_range) = request.zero_range {
318 let zero_yards = zero_range / 0.9144;
319 req = req.query("zero_distance", format!("{:.1}", zero_yards));
320 }
321 if let Some(wind_speed) = request.wind_speed {
322 let wind_mph = wind_speed * 2.23694; req = req.query("wind_speed", format!("{:.1}", wind_mph));
324 }
325 if let Some(wind_angle) = request.wind_angle {
326 req = req.query("wind_angle", format!("{:.1}", wind_angle));
327 }
328 if let Some(temp) = request.temperature {
329 let temp_f = temp * 9.0 / 5.0 + 32.0; req = req.query("temperature", format!("{:.1}", temp_f));
331 }
332 if let Some(pressure) = request.pressure {
333 let pressure_inhg = pressure / 33.8639; req = req.query("pressure", format!("{:.2}", pressure_inhg));
335 }
336 if let Some(humidity) = request.humidity {
337 req = req.query("humidity", format!("{:.1}", humidity));
338 }
339 if let Some(altitude) = request.altitude {
340 let altitude_ft = altitude / 0.3048; req = req.query("altitude", format!("{:.1}", altitude_ft));
342 }
343 if let Some(shooting_angle) = request.shooting_angle {
344 req = req.query("shooting_angle", format!("{:.1}", shooting_angle));
345 }
346 if let Some(latitude) = request.latitude {
347 req = req.query("latitude", format!("{:.2}", latitude));
348 }
349 if let Some(longitude) = request.longitude {
350 req = req.query("longitude", format!("{:.2}", longitude));
351 }
352 if let Some(shot_direction) = request.shot_direction {
353 req = req.query("shot_direction", format!("{:.1}", shot_direction));
354 }
355 if let Some(twist_rate) = request.twist_rate {
356 req = req.query("twist_rate", format!("{:.1}", twist_rate));
357 }
358 if let Some(diameter) = request.bullet_diameter {
359 let diameter_in = diameter / 0.0254; req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
361 }
362 if let Some(threshold) = request.ground_threshold {
363 if threshold.is_finite() {
370 req = req.query("ground_threshold", format!("{:.1}", threshold));
371 } else if threshold == f64::NEG_INFINITY {
372 req = req.query("ground_threshold", "-1000000000.0");
373 }
374 }
375 if let Some(enable) = request.enable_weather_zones {
376 req = req.query(
377 "enable_weather_zones",
378 if enable { "true" } else { "false" },
379 );
380 }
381 if let Some(enable) = request.enable_3d_weather {
382 req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
383 }
384 if let Some(ref model) = request.wind_shear_model {
385 req = req.query("wind_shear_model", model);
386 }
387 if let Some(ref method) = request.weather_zone_interpolation {
388 req = req.query("weather_zone_interpolation", method);
389 }
390 if let Some(sample_interval) = request.sample_interval {
391 let step_yards = sample_interval / 0.9144;
393 req = req.query("trajectory_step", format!("{:.4}", step_yards));
394 }
395
396 let mut response = req.call().map_err(map_ureq_error)?;
397
398 let status = response.status();
402 if !status.is_success() {
403 let body = response.body_mut().read_to_string().unwrap_or_default();
404 return Err(ApiError::ServerError(status.as_u16(), body));
405 }
406
407 let body = response
408 .body_mut()
409 .read_to_string()
410 .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
411
412 let api_response: serde_json::Value = serde_json::from_str(&body)
414 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
415
416 self.convert_api_response(&api_response)
418 }
419
420 #[cfg(feature = "online")]
422 fn extract_value(val: &serde_json::Value) -> Option<f64> {
423 val.get("value")
425 .and_then(|v| v.as_f64())
426 .or_else(|| val.as_f64())
427 }
428
429 #[cfg(feature = "online")]
430 fn convert_api_response(
431 &self,
432 api_response: &serde_json::Value,
433 ) -> Result<TrajectoryResponse, ApiError> {
434 let results = api_response.get("results");
436
437 let trajectory_array = api_response
440 .get("trajectory")
441 .and_then(|t| t.as_array())
442 .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
443
444 let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
445 .iter()
446 .filter_map(|point| {
447 let range_yards = point.get("distance").and_then(Self::extract_value)?;
449 let drop_inches = point
450 .get("drop")
451 .and_then(Self::extract_value)
452 .unwrap_or(0.0);
453 let drift_inches = point
454 .get("wind_drift")
455 .and_then(Self::extract_value)
456 .unwrap_or(0.0);
457 let velocity_fps = point.get("velocity").and_then(Self::extract_value)?;
458 let energy_ftlbs = point
459 .get("energy")
460 .and_then(Self::extract_value)
461 .unwrap_or(0.0);
462 let time = point
463 .get("time")
464 .and_then(Self::extract_value)
465 .unwrap_or(0.0);
466
467 Some(ApiTrajectoryPoint {
468 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,
474 })
475 })
476 .collect();
477
478 let zero_angle = results
480 .and_then(|r| r.get("barrel_angle"))
481 .and_then(Self::extract_value)
482 .unwrap_or(0.0)
483 .to_radians();
484
485 let time_of_flight = results
486 .and_then(|r| r.get("time_of_flight"))
487 .and_then(Self::extract_value)
488 .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
489
490 let bc_confidence = api_response.get("bc_confidence").and_then(|v| v.as_f64());
491
492 let ml_corrections = api_response
493 .get("ml_corrections_applied")
494 .or_else(|| api_response.get("corrections_applied"))
495 .and_then(|v| v.as_array())
496 .map(|arr| {
497 arr.iter()
498 .filter_map(|v| v.as_str().map(String::from))
499 .collect()
500 });
501
502 let max_ordinate = results
504 .and_then(|r| r.get("max_height"))
505 .and_then(|v| v.as_f64())
506 .map(|h| h * 0.0254); let impact_velocity = results
509 .and_then(|r| r.get("final_velocity"))
510 .and_then(Self::extract_value)
511 .map(|v| v * 0.3048); let impact_energy = results
514 .and_then(|r| r.get("final_energy"))
515 .and_then(Self::extract_value)
516 .map(|e| e * 1.35582); Ok(TrajectoryResponse {
519 trajectory,
520 zero_angle,
521 time_of_flight,
522 bc_confidence,
523 ml_corrections_applied: ml_corrections,
524 max_ordinate,
525 impact_velocity,
526 impact_energy,
527 })
528 }
529
530 #[cfg(feature = "online")]
532 pub fn health_check(&self) -> Result<bool, ApiError> {
533 let url = format!("{}/health", self.base_url);
534
535 let response = ureq::get(&url)
536 .config()
537 .timeout_global(Some(Duration::from_secs(5)))
538 .build()
539 .call()
540 .map_err(map_ureq_error)?;
541
542 Ok(response.status().as_u16() == 200)
543 }
544
545 #[cfg(feature = "online")]
554 pub fn true_velocity(
555 &self,
556 request: &TrueVelocityRequest,
557 ) -> Result<TrueVelocityResponse, ApiError> {
558 let url = format!("{}/v1/true-velocity", self.base_url);
559
560 let body = serde_json::to_string(request)
561 .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
562
563 let mut req = ureq::post(&url)
564 .config()
565 .timeout_global(Some(self.timeout))
566 .build()
567 .header("Content-Type", "application/json")
568 .header("Accept", "application/json")
569 .header(
570 "User-Agent",
571 &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
572 );
573 if let Some((name, value)) = self.auth_header() {
574 req = req.header(name, &value);
575 }
576 let mut response = req.send(&body).map_err(map_ureq_error)?;
577
578 let status = response.status();
580 if !status.is_success() {
581 let body = response.body_mut().read_to_string().unwrap_or_default();
582 return Err(ApiError::ServerError(status.as_u16(), body));
583 }
584
585 let response_body = response
586 .body_mut()
587 .read_to_string()
588 .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
589
590 serde_json::from_str(&response_body)
591 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
592 }
593
594 #[cfg(feature = "online")]
597 pub fn post_json(
598 &self,
599 path: &str,
600 body: &serde_json::Value,
601 ) -> Result<serde_json::Value, ApiError> {
602 let url = format!("{}{}", self.base_url, path);
603 let body_str = serde_json::to_string(body)
604 .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
605 let mut req = ureq::post(&url)
606 .config()
607 .timeout_global(Some(self.timeout))
608 .build()
609 .header("Content-Type", "application/json")
610 .header("Accept", "application/json")
611 .header(
612 "User-Agent",
613 &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
614 );
615 if let Some((name, value)) = self.auth_header() {
616 req = req.header(name, &value);
617 }
618 let mut response = req.send(&body_str).map_err(map_ureq_error)?;
619 let status = response.status();
620 let text = response
621 .body_mut()
622 .read_to_string()
623 .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
624 if !status.is_success() {
625 return Err(ApiError::ServerError(status.as_u16(), text));
626 }
627 serde_json::from_str(&text)
628 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
629 }
630
631 #[cfg(feature = "online")]
634 pub fn get_json(
635 &self,
636 path: &str,
637 params: &[(String, String)],
638 ) -> Result<serde_json::Value, ApiError> {
639 let url = format!("{}{}", self.base_url, path);
640 let mut req = ureq::get(&url)
641 .config()
642 .timeout_global(Some(self.timeout))
643 .build()
644 .header("Accept", "application/json")
645 .header(
646 "User-Agent",
647 &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
648 );
649 for (k, v) in params {
650 req = req.query(k.as_str(), v.as_str());
651 }
652 if let Some((name, value)) = self.auth_header() {
653 req = req.header(name, &value);
654 }
655 let mut response = req.call().map_err(map_ureq_error)?;
656 let status = response.status();
657 let text = response
658 .body_mut()
659 .read_to_string()
660 .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
661 if !status.is_success() {
662 return Err(ApiError::ServerError(status.as_u16(), text));
663 }
664 serde_json::from_str(&text)
665 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
666 }
667}
668
669#[cfg(feature = "online")]
676fn map_ureq_error(e: ureq::Error) -> ApiError {
677 match e {
678 ureq::Error::Timeout(_) => ApiError::Timeout,
680 ureq::Error::Io(io_err) => {
682 if io_err.kind() == std::io::ErrorKind::TimedOut {
683 ApiError::Timeout
684 } else {
685 ApiError::NetworkError(io_err.to_string())
686 }
687 }
688 other => ApiError::NetworkError(other.to_string()),
690 }
691}
692
693#[derive(Default)]
695pub struct TrajectoryRequestBuilder {
696 bc_value: Option<f64>,
697 bc_type: Option<String>,
698 bullet_mass: Option<f64>,
699 muzzle_velocity: Option<f64>,
700 target_distance: Option<f64>,
701 zero_range: Option<f64>,
702 wind_speed: Option<f64>,
703 wind_angle: Option<f64>,
704 temperature: Option<f64>,
705 pressure: Option<f64>,
706 humidity: Option<f64>,
707 altitude: Option<f64>,
708 latitude: Option<f64>,
709 longitude: Option<f64>,
710 shot_direction: Option<f64>,
711 shooting_angle: Option<f64>,
712 twist_rate: Option<f64>,
713 bullet_diameter: Option<f64>,
714 bullet_length: Option<f64>,
715 ground_threshold: Option<f64>,
716 enable_weather_zones: Option<bool>,
717 enable_3d_weather: Option<bool>,
718 wind_shear_model: Option<String>,
719 weather_zone_interpolation: Option<String>,
720 sample_interval: Option<f64>,
721}
722
723impl TrajectoryRequestBuilder {
724 pub fn new() -> Self {
725 Self::default()
726 }
727
728 pub fn bc_value(mut self, value: f64) -> Self {
729 self.bc_value = Some(value);
730 self
731 }
732
733 pub fn bc_type(mut self, value: &str) -> Self {
734 self.bc_type = Some(value.to_string());
735 self
736 }
737
738 pub fn bullet_mass(mut self, value: f64) -> Self {
739 self.bullet_mass = Some(value);
740 self
741 }
742
743 pub fn muzzle_velocity(mut self, value: f64) -> Self {
744 self.muzzle_velocity = Some(value);
745 self
746 }
747
748 pub fn target_distance(mut self, value: f64) -> Self {
749 self.target_distance = Some(value);
750 self
751 }
752
753 pub fn zero_range(mut self, value: f64) -> Self {
754 self.zero_range = Some(value);
755 self
756 }
757
758 pub fn wind_speed(mut self, value: f64) -> Self {
759 self.wind_speed = Some(value);
760 self
761 }
762
763 pub fn wind_angle(mut self, value: f64) -> Self {
764 self.wind_angle = Some(value);
765 self
766 }
767
768 pub fn temperature(mut self, value: f64) -> Self {
769 self.temperature = Some(value);
770 self
771 }
772
773 pub fn pressure(mut self, value: f64) -> Self {
774 self.pressure = Some(value);
775 self
776 }
777
778 pub fn humidity(mut self, value: f64) -> Self {
779 self.humidity = Some(value);
780 self
781 }
782
783 pub fn altitude(mut self, value: f64) -> Self {
784 self.altitude = Some(value);
785 self
786 }
787
788 pub fn latitude(mut self, value: f64) -> Self {
789 self.latitude = Some(value);
790 self
791 }
792
793 pub fn longitude(mut self, value: f64) -> Self {
794 self.longitude = Some(value);
795 self
796 }
797
798 pub fn shot_direction(mut self, value: f64) -> Self {
799 self.shot_direction = Some(value);
800 self
801 }
802
803 pub fn shooting_angle(mut self, value: f64) -> Self {
804 self.shooting_angle = Some(value);
805 self
806 }
807
808 pub fn twist_rate(mut self, value: f64) -> Self {
809 self.twist_rate = Some(value);
810 self
811 }
812
813 pub fn bullet_diameter(mut self, value: f64) -> Self {
814 self.bullet_diameter = Some(value);
815 self
816 }
817
818 pub fn bullet_length(mut self, value: f64) -> Self {
819 self.bullet_length = Some(value);
820 self
821 }
822
823 pub fn ground_threshold(mut self, value: f64) -> Self {
824 self.ground_threshold = Some(value);
825 self
826 }
827
828 pub fn enable_weather_zones(mut self, value: bool) -> Self {
829 self.enable_weather_zones = Some(value);
830 self
831 }
832
833 pub fn enable_3d_weather(mut self, value: bool) -> Self {
834 self.enable_3d_weather = Some(value);
835 self
836 }
837
838 pub fn wind_shear_model(mut self, value: &str) -> Self {
839 self.wind_shear_model = Some(value.to_string());
840 self
841 }
842
843 pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
844 self.weather_zone_interpolation = Some(value.to_string());
845 self
846 }
847
848 pub fn sample_interval(mut self, value: f64) -> Self {
849 self.sample_interval = Some(value);
850 self
851 }
852
853 pub fn build(self) -> Result<TrajectoryRequest, String> {
859 let bc_value = self.bc_value.ok_or("bc_value is required")?;
860 let bc_type = self.bc_type.ok_or("bc_type is required")?;
861 let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
862 let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
863 let target_distance = self.target_distance.ok_or("target_distance is required")?;
864
865 Ok(TrajectoryRequest {
866 bc_value,
867 bc_type,
868 bullet_mass,
869 muzzle_velocity,
870 target_distance,
871 zero_range: self.zero_range,
872 wind_speed: self.wind_speed,
873 wind_angle: self.wind_angle,
874 temperature: self.temperature,
875 pressure: self.pressure,
876 humidity: self.humidity,
877 altitude: self.altitude,
878 latitude: self.latitude,
879 longitude: self.longitude,
880 shot_direction: self.shot_direction,
881 shooting_angle: self.shooting_angle,
882 twist_rate: self.twist_rate,
883 bullet_diameter: self.bullet_diameter,
884 bullet_length: self.bullet_length,
885 ground_threshold: self.ground_threshold,
886 enable_weather_zones: self.enable_weather_zones,
887 enable_3d_weather: self.enable_3d_weather,
888 wind_shear_model: self.wind_shear_model,
889 weather_zone_interpolation: self.weather_zone_interpolation,
890 sample_interval: self.sample_interval,
891 })
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn test_request_builder_required_fields() {
901 let result = TrajectoryRequestBuilder::new()
902 .bc_value(0.238)
903 .bc_type("G7")
904 .bullet_mass(9.07) .muzzle_velocity(860.0)
906 .target_distance(1000.0)
907 .build();
908
909 assert!(result.is_ok());
910 let request = result.unwrap();
911 assert_eq!(request.bc_value, 0.238);
912 assert_eq!(request.bc_type, "G7");
913 }
914
915 #[test]
916 fn test_request_builder_missing_fields() {
917 let result = TrajectoryRequestBuilder::new().bc_value(0.238).build();
918
919 assert!(result.is_err());
920 }
921
922 #[test]
923 fn test_request_builder_all_optional_fields() {
924 let result = TrajectoryRequestBuilder::new()
925 .bc_value(0.238)
926 .bc_type("G7")
927 .bullet_mass(9.07)
928 .muzzle_velocity(860.0)
929 .target_distance(1000.0)
930 .zero_range(100.0)
931 .wind_speed(5.0)
932 .wind_angle(90.0)
933 .temperature(15.0)
934 .pressure(1013.25)
935 .humidity(50.0)
936 .altitude(500.0)
937 .latitude(45.0)
938 .shooting_angle(0.0)
939 .twist_rate(10.0)
940 .bullet_diameter(0.00671)
941 .bullet_length(0.035)
942 .build();
943
944 assert!(result.is_ok());
945 let request = result.unwrap();
946 assert_eq!(request.zero_range, Some(100.0));
947 assert_eq!(request.wind_speed, Some(5.0));
948 assert_eq!(request.latitude, Some(45.0));
949 }
950
951 #[test]
952 fn test_api_client_url_normalization() {
953 let client1 = ApiClient::new("https://api.example.com/", 10);
954 assert_eq!(client1.base_url, "https://api.example.com");
955
956 let client2 = ApiClient::new("https://api.example.com", 10);
957 assert_eq!(client2.base_url, "https://api.example.com");
958 }
959
960 #[test]
961 fn test_api_error_display() {
962 assert_eq!(
963 format!(
964 "{}",
965 ApiError::NetworkError("connection refused".to_string())
966 ),
967 "Network error: connection refused"
968 );
969 assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
970 assert_eq!(
971 format!(
972 "{}",
973 ApiError::ServerError(500, "Internal error".to_string())
974 ),
975 "Server error 500: Internal error"
976 );
977 }
978
979 #[cfg(feature = "online")]
980 #[test]
981 fn auth_header_present_when_token_set() {
982 let c = ApiClient::new("https://x", 5).with_token(Some("bpat_abc".into()));
983 assert_eq!(
984 c.auth_header(),
985 Some(("Authorization", "Bearer bpat_abc".to_string()))
986 );
987 }
988
989 #[cfg(feature = "online")]
990 #[test]
991 fn auth_header_absent_without_token() {
992 let c = ApiClient::new("https://x", 5);
993 assert!(c.auth_header().is_none());
994 }
995
996 #[test]
997 fn cli_hint_maps_auth_errors() {
998 assert!(ApiError::ServerError(401, "x".into())
999 .cli_hint()
1000 .unwrap()
1001 .contains("login"));
1002 assert!(ApiError::ServerError(402, "x".into())
1003 .cli_hint()
1004 .unwrap()
1005 .contains("subscription"));
1006 assert!(ApiError::ServerError(500, "x".into()).cli_hint().is_none());
1007 }
1008}