1use std::fs::{self, File};
21use std::io::{Read, Write};
22use std::path::PathBuf;
23use std::time::Duration;
24
25pub const DEFAULT_BC5D_URL: &str = "https://ballistics.tools/downloads/bc5d";
27
28const DOWNLOAD_TIMEOUT_SECS: u64 = 60;
30
31const MANIFEST_FILE: &str = "manifest.json";
33
34#[derive(Debug)]
36pub enum Bc5dDownloadError {
37 NetworkError(String),
39 Timeout,
41 IoError(std::io::Error),
43 ChecksumMismatch { expected: String, actual: String },
45 CaliberNotAvailable { requested: f64, available: Vec<f64> },
47 ManifestParseError(String),
49 CacheDirectoryError(String),
51}
52
53impl std::fmt::Display for Bc5dDownloadError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Bc5dDownloadError::NetworkError(msg) => write!(f, "Network error: {}", msg),
57 Bc5dDownloadError::Timeout => write!(f, "Download timed out"),
58 Bc5dDownloadError::IoError(e) => write!(f, "IO error: {}", e),
59 Bc5dDownloadError::ChecksumMismatch { expected, actual } => {
60 write!(
61 f,
62 "Checksum mismatch: expected {}, got {}",
63 expected, actual
64 )
65 }
66 Bc5dDownloadError::CaliberNotAvailable {
67 requested,
68 available,
69 } => {
70 let available_str: Vec<String> = available
71 .iter()
72 .map(|c| format!(".{}", (c * 1000.0) as i32))
73 .collect();
74 write!(
75 f,
76 "No BC5D table available for caliber {:.3} ({:.1}mm)\nAvailable calibers: {}",
77 requested,
78 requested * 25.4,
79 available_str.join(", ")
80 )
81 }
82 Bc5dDownloadError::ManifestParseError(msg) => {
83 write!(f, "Manifest parse error: {}", msg)
84 }
85 Bc5dDownloadError::CacheDirectoryError(msg) => {
86 write!(f, "Cache directory error: {}", msg)
87 }
88 }
89 }
90}
91
92impl std::error::Error for Bc5dDownloadError {}
93
94impl From<std::io::Error> for Bc5dDownloadError {
95 fn from(e: std::io::Error) -> Self {
96 Bc5dDownloadError::IoError(e)
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct TableEntry {
103 pub file: String,
105 pub size: u64,
107 pub crc32: String,
109}
110
111#[derive(Debug, Clone)]
113pub struct Bc5dManifest {
114 pub version: String,
116 pub generated: String,
118 pub tables: std::collections::HashMap<String, TableEntry>,
120}
121
122pub struct Bc5dDownloader {
124 base_url: String,
126 cache_dir: PathBuf,
128 force_refresh: bool,
130 manifest: Option<Bc5dManifest>,
132}
133
134impl Bc5dDownloader {
135 pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
144 let cache_dir = get_cache_directory()?;
145
146 if !cache_dir.exists() {
148 fs::create_dir_all(&cache_dir).map_err(|e| {
149 Bc5dDownloadError::CacheDirectoryError(format!(
150 "Failed to create cache directory {}: {}",
151 cache_dir.display(),
152 e
153 ))
154 })?;
155 }
156
157 Ok(Bc5dDownloader {
158 base_url: base_url.trim_end_matches('/').to_string(),
159 cache_dir,
160 force_refresh,
161 manifest: None,
162 })
163 }
164
165 pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
175 if self.manifest.is_none() {
177 self.manifest = Some(self.fetch_manifest()?);
178 }
179 let manifest = self.manifest.as_ref().unwrap();
180
181 let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
183
184 let entry = manifest.tables.get(&caliber_key).ok_or_else(|| {
186 Bc5dDownloadError::CaliberNotAvailable {
187 requested: caliber,
188 available: self.available_calibers_from_manifest(manifest),
189 }
190 })?;
191
192 let cached_path = self.cache_dir.join(&entry.file);
194 if !self.force_refresh && cached_path.exists() {
195 if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
197 if actual_crc == entry.crc32 {
198 return Ok(cached_path);
199 }
200 eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
202 }
203 }
204
205 self.download_table(&entry.file, &cached_path, &entry.crc32)?;
207
208 Ok(cached_path)
209 }
210
211 pub fn available_calibers(&mut self) -> Result<Vec<f64>, Bc5dDownloadError> {
213 if self.manifest.is_none() {
214 self.manifest = Some(self.fetch_manifest()?);
215 }
216 Ok(self.available_calibers_from_manifest(self.manifest.as_ref().unwrap()))
217 }
218
219 pub fn cache_dir(&self) -> &PathBuf {
221 &self.cache_dir
222 }
223
224 pub fn is_cached(&self, caliber: f64) -> bool {
226 let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
227 let filename = self
231 .manifest
232 .as_ref()
233 .and_then(|m| m.tables.get(&caliber_key))
234 .map(|entry| entry.file.clone())
235 .unwrap_or_else(|| format!("bc5d_{}.bin", caliber_key));
236 self.cache_dir.join(&filename).exists()
237 }
238
239 fn available_calibers_from_manifest(&self, manifest: &Bc5dManifest) -> Vec<f64> {
241 let mut calibers: Vec<f64> = manifest
242 .tables
243 .keys()
244 .filter_map(|k| k.parse::<i32>().ok())
245 .map(|k| k as f64 / 1000.0)
246 .collect();
247 calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
248 calibers
249 }
250
251 #[cfg(feature = "online")]
253 fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
254 let url = format!("{}/{}", self.base_url, MANIFEST_FILE);
255
256 let mut response = ureq::get(&url)
257 .config()
258 .timeout_global(Some(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)))
259 .build()
260 .call()
261 .map_err(|e| match e {
262 ureq::Error::Io(io_err) => {
263 Bc5dDownloadError::NetworkError(format!("Connection failed: {}", io_err))
264 }
265 ureq::Error::Timeout(_) => {
266 Bc5dDownloadError::NetworkError("Connection failed: timed out".to_string())
267 }
268 _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
269 })?;
270
271 let json: serde_json::Value = response.body_mut().read_json().map_err(|e| {
272 Bc5dDownloadError::ManifestParseError(format!("Failed to parse JSON: {}", e))
273 })?;
274
275 let version = json["version"].as_str().unwrap_or("unknown").to_string();
277 let generated = json["generated"].as_str().unwrap_or("unknown").to_string();
278
279 let tables_obj = json["tables"].as_object().ok_or_else(|| {
280 Bc5dDownloadError::ManifestParseError("Missing 'tables' field".to_string())
281 })?;
282
283 let mut tables = std::collections::HashMap::new();
284 for (caliber, entry) in tables_obj {
285 let file = entry["file"]
286 .as_str()
287 .ok_or_else(|| {
288 Bc5dDownloadError::ManifestParseError(format!(
289 "Missing 'file' for caliber {}",
290 caliber
291 ))
292 })?
293 .to_string();
294 let size = entry["size"].as_u64().ok_or_else(|| {
295 Bc5dDownloadError::ManifestParseError(format!(
296 "Missing 'size' for caliber {}",
297 caliber
298 ))
299 })?;
300 let crc32 = entry["crc32"]
301 .as_str()
302 .ok_or_else(|| {
303 Bc5dDownloadError::ManifestParseError(format!(
304 "Missing 'crc32' for caliber {}",
305 caliber
306 ))
307 })?
308 .to_string();
309
310 tables.insert(caliber.clone(), TableEntry { file, size, crc32 });
311 }
312
313 Ok(Bc5dManifest {
314 version,
315 generated,
316 tables,
317 })
318 }
319
320 #[cfg(not(feature = "online"))]
322 fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
323 Err(Bc5dDownloadError::NetworkError(
324 "Online features not enabled. Build with --features online".to_string(),
325 ))
326 }
327
328 #[cfg(feature = "online")]
330 fn download_table(
331 &self,
332 filename: &str,
333 dest_path: &PathBuf,
334 expected_crc: &str,
335 ) -> Result<(), Bc5dDownloadError> {
336 let url = format!("{}/{}", self.base_url, filename);
337
338 eprintln!("Downloading BC5D table: {}...", filename);
339
340 let response = ureq::get(&url)
341 .config()
342 .timeout_global(Some(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)))
343 .build()
344 .call()
345 .map_err(|e| match e {
346 ureq::Error::Io(io_err) => {
347 Bc5dDownloadError::NetworkError(format!("Connection failed: {}", io_err))
348 }
349 ureq::Error::Timeout(_) => {
350 Bc5dDownloadError::NetworkError("Connection failed: timed out".to_string())
351 }
352 _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
353 })?;
354
355 let mut data = Vec::new();
358 response
359 .into_body()
360 .into_reader()
361 .read_to_end(&mut data)
362 .map_err(|e| {
363 Bc5dDownloadError::NetworkError(format!("Failed to read response: {}", e))
364 })?;
365
366 let actual_crc = calculate_crc32(&data);
368 if actual_crc != expected_crc {
369 return Err(Bc5dDownloadError::ChecksumMismatch {
370 expected: expected_crc.to_string(),
371 actual: actual_crc,
372 });
373 }
374
375 let mut file = File::create(dest_path)?;
377 file.write_all(&data)?;
378
379 eprintln!("Downloaded {} ({} bytes)", filename, data.len());
380
381 Ok(())
382 }
383
384 #[cfg(not(feature = "online"))]
386 fn download_table(
387 &self,
388 _filename: &str,
389 _dest_path: &PathBuf,
390 _expected_crc: &str,
391 ) -> Result<(), Bc5dDownloadError> {
392 Err(Bc5dDownloadError::NetworkError(
393 "Online features not enabled. Build with --features online".to_string(),
394 ))
395 }
396}
397
398pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
400 if let Some(cache_dir) = dirs::cache_dir() {
402 return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
403 }
404
405 if let Some(home) = dirs::home_dir() {
407 #[cfg(target_os = "macos")]
408 return Ok(home
409 .join("Library")
410 .join("Caches")
411 .join("ballistics-engine")
412 .join("bc5d"));
413
414 #[cfg(target_os = "windows")]
415 return Ok(home
416 .join("AppData")
417 .join("Local")
418 .join("ballistics-engine")
419 .join("cache")
420 .join("bc5d"));
421
422 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
423 return Ok(home.join(".cache").join("ballistics-engine").join("bc5d"));
424 }
425
426 Err(Bc5dDownloadError::CacheDirectoryError(
427 "Could not determine cache directory".to_string(),
428 ))
429}
430
431fn calculate_crc32(data: &[u8]) -> String {
433 const TABLE: [u32; 256] = make_crc32_table();
434 let mut crc = 0xFFFFFFFFu32;
435 for &byte in data {
436 let idx = ((crc ^ byte as u32) & 0xFF) as usize;
437 crc = (crc >> 8) ^ TABLE[idx];
438 }
439 format!("{:08x}", !crc)
440}
441
442fn calculate_file_crc32(path: &PathBuf) -> Result<String, std::io::Error> {
444 let mut file = File::open(path)?;
445 let mut data = Vec::new();
446 file.read_to_end(&mut data)?;
447 Ok(calculate_crc32(&data))
448}
449
450const fn make_crc32_table() -> [u32; 256] {
452 const POLY: u32 = 0xEDB88320;
453 let mut table = [0u32; 256];
454 let mut i = 0;
455 while i < 256 {
456 let mut crc = i as u32;
457 let mut j = 0;
458 while j < 8 {
459 if crc & 1 != 0 {
460 crc = (crc >> 1) ^ POLY;
461 } else {
462 crc >>= 1;
463 }
464 j += 1;
465 }
466 table[i] = crc;
467 i += 1;
468 }
469 table
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn test_crc32_calculation() {
478 let data = b"123456789";
480 let crc = calculate_crc32(data);
481 assert_eq!(crc, "cbf43926");
482 }
483
484 #[test]
485 fn test_cache_directory() {
486 let cache_dir = get_cache_directory();
487 assert!(cache_dir.is_ok());
488 let path = cache_dir.unwrap();
489 assert!(path.to_string_lossy().contains("bc5d"));
490 }
491
492 #[test]
493 fn test_caliber_key_conversion() {
494 let caliber: f64 = 0.308;
496 let key = format!("{}", (caliber * 1000.0).round() as i32);
497 assert_eq!(key, "308");
498
499 let caliber: f64 = 0.224;
500 let key = format!("{}", (caliber * 1000.0).round() as i32);
501 assert_eq!(key, "224");
502 }
503
504 #[test]
505 fn test_error_display() {
506 let err = Bc5dDownloadError::CaliberNotAvailable {
507 requested: 0.375,
508 available: vec![0.224, 0.308, 0.338],
509 };
510 let msg = format!("{}", err);
511 assert!(msg.contains("0.375"));
512 assert!(msg.contains("9.5mm"));
513 assert!(msg.contains(".224"));
514 assert!(msg.contains(".308"));
515 assert!(msg.contains(".338"));
516 }
517}