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")]
45 pub temperature_supplied: Option<bool>,
46 #[serde(skip_serializing_if = "Option::is_none")]
49 pub pressure_supplied: Option<bool>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub humidity: Option<f64>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub altitude: Option<f64>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub latitude: Option<f64>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub longitude: Option<f64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub shot_direction: Option<f64>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub shooting_angle: Option<f64>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub twist_rate: Option<f64>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub bullet_diameter: Option<f64>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub bullet_length: Option<f64>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub ground_threshold: Option<f64>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub enable_weather_zones: Option<bool>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub enable_3d_weather: Option<bool>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub wind_shear_model: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub weather_zone_interpolation: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub sample_interval: Option<f64>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100pub struct TrajectoryResponse {
101 pub trajectory: Vec<ApiTrajectoryPoint>,
103 pub zero_angle: f64,
105 pub time_of_flight: f64,
107 #[serde(default)]
109 pub bc_confidence: Option<f64>,
110 #[serde(default)]
112 pub ml_corrections_applied: Option<Vec<String>>,
113 #[serde(default)]
115 pub max_ordinate: Option<f64>,
116 #[serde(default)]
118 pub impact_velocity: Option<f64>,
119 #[serde(default)]
121 pub impact_energy: Option<f64>,
122}
123
124#[derive(Debug, Clone, Deserialize)]
126pub struct ApiTrajectoryPoint {
127 pub range: f64,
129 pub drop: f64,
131 pub drift: f64,
133 pub velocity: f64,
135 pub energy: f64,
137 pub time: f64,
139}
140
141#[derive(Debug, Clone, Serialize)]
143pub struct TrueVelocityRequest {
144 pub measured_drop_mil: f64,
146 pub range_yd: f64,
148 pub bc: f64,
150 pub drag_model: String,
152 pub weight_gr: f64,
154 pub caliber: f64,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub zero_range_yd: Option<f64>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub chrono_velocity_fps: Option<f64>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub altitude_ft: Option<f64>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub temperature_f: Option<f64>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub pressure_inhg: Option<f64>,
171 #[serde(skip_serializing_if = "Option::is_none")]
174 pub temperature_supplied: Option<bool>,
175 #[serde(skip_serializing_if = "Option::is_none")]
178 pub pressure_supplied: Option<bool>,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub humidity: Option<f64>,
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub sight_height_in: Option<f64>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub use_bc_enhancement: Option<bool>,
188}
189
190#[derive(Debug, Clone, Deserialize)]
192pub struct TrueVelocityResponse {
193 pub effective_velocity_fps: f64,
195 #[serde(default)]
197 pub velocity_adjustment_fps: Option<f64>,
198 #[serde(default)]
200 pub adjustment_percent: Option<f64>,
201 pub confidence: String,
203 pub iterations: i32,
205 pub final_error_mil: f64,
207 pub calculated_drop_mil: f64,
209}
210
211#[derive(Debug)]
213pub enum ApiError {
214 NetworkError(String),
216 Timeout,
218 InvalidResponse(String),
220 ServerError(u16, String),
222 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 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
255pub 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 pub fn new(base_url: &str, timeout_secs: u64) -> Self {
270 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 pub fn with_token(mut self, token: Option<String>) -> Self {
282 self.token = token;
283 self
284 }
285
286 #[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 #[cfg(feature = "online")]
303 pub fn calculate_trajectory(
304 &self,
305 request: &TrajectoryRequest,
306 ) -> Result<TrajectoryResponse, ApiError> {
307 let url = format!("{}/v1/calculate", self.base_url);
309
310 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)
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 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; 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; req = req.query("temperature", format!("{:.1}", temp_f));
349 }
350 if let Some(pressure) = request.pressure {
351 let pressure_inhg = pressure / 33.8639; req = req.query("pressure", format!("{:.2}", pressure_inhg));
353 }
354 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; 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; req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
387 }
388 if let Some(threshold) = request.ground_threshold {
389 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 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 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 let api_response: serde_json::Value = serde_json::from_str(&body)
440 .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
441
442 self.convert_api_response(&api_response)
444 }
445
446 #[cfg(feature = "online")]
448 fn extract_value(val: &serde_json::Value) -> Option<f64> {
449 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 let results = api_response.get("results");
462
463 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 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, drop: drop_inches * 0.0254, drift: drift_inches * 0.0254, velocity: velocity_fps * 0.3048, energy: energy_ftlbs * 1.35582, time,
500 })
501 })
502 .collect();
503
504 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 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); let impact_velocity = results
535 .and_then(|r| r.get("final_velocity"))
536 .and_then(Self::extract_value)
537 .map(|v| v * 0.3048); let impact_energy = results
540 .and_then(|r| r.get("final_energy"))
541 .and_then(Self::extract_value)
542 .map(|e| e * 1.35582); 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 #[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 #[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 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 #[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 #[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#[cfg(feature = "online")]
702fn map_ureq_error(e: ureq::Error) -> ApiError {
703 match e {
704 ureq::Error::Timeout(_) => ApiError::Timeout,
706 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 => ApiError::NetworkError(other.to_string()),
716 }
717}
718
719#[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 pub fn temperature_supplied(mut self, supplied: bool) -> Self {
809 self.temperature_supplied = supplied.then_some(true);
810 self
811 }
812
813 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 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) .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 #[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 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}