Skip to main content

ballistics_engine/
bc_table_download.rs

1//! BC5D Table Auto-Download Module
2//!
3//! This module provides automatic downloading and caching of BC5D correction tables
4//! from a remote server. Tables are cached locally to avoid repeated downloads.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use ballistics_engine::bc_table_download::Bc5dDownloader;
10//!
11//! let mut downloader = Bc5dDownloader::new(
12//!     "https://ballistics.tools/downloads/bc5d",
13//!     false
14//! ).unwrap();
15//!
16//! // Download table for .308 caliber
17//! let table_path = downloader.ensure_table(0.308).unwrap();
18//! ```
19
20use std::fs::{self, File};
21use std::io::{Read, Write};
22use std::path::PathBuf;
23use std::time::Duration;
24
25/// Default URL for BC5D table downloads
26pub const DEFAULT_BC5D_URL: &str = "https://ballistics.tools/downloads/bc5d";
27
28/// Download timeout in seconds
29const DOWNLOAD_TIMEOUT_SECS: u64 = 60;
30
31/// Manifest file name
32const MANIFEST_FILE: &str = "manifest.json";
33
34/// Error type for BC5D table download operations
35#[derive(Debug)]
36pub enum Bc5dDownloadError {
37    /// Network error during download
38    NetworkError(String),
39    /// Request timed out
40    Timeout,
41    /// IO error reading/writing files
42    IoError(std::io::Error),
43    /// CRC32 checksum mismatch after download
44    ChecksumMismatch { expected: String, actual: String },
45    /// Requested caliber not available
46    CaliberNotAvailable { requested: f64, available: Vec<f64> },
47    /// Failed to parse manifest
48    ManifestParseError(String),
49    /// Cache directory could not be created
50    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/// Manifest entry for a BC5D table
101#[derive(Debug, Clone)]
102pub struct TableEntry {
103    /// Filename
104    pub file: String,
105    /// File size in bytes
106    pub size: u64,
107    /// CRC32 checksum (hex string)
108    pub crc32: String,
109}
110
111/// Manifest describing available BC5D tables
112#[derive(Debug, Clone)]
113pub struct Bc5dManifest {
114    /// Manifest version
115    pub version: String,
116    /// When the manifest was generated
117    pub generated: String,
118    /// Map of caliber (as string like "308") to table entry
119    pub tables: std::collections::HashMap<String, TableEntry>,
120}
121
122/// BC5D table downloader with caching
123pub struct Bc5dDownloader {
124    /// Base URL for downloads
125    base_url: String,
126    /// Local cache directory
127    cache_dir: PathBuf,
128    /// Force re-download even if cached
129    force_refresh: bool,
130    /// Cached manifest
131    manifest: Option<Bc5dManifest>,
132}
133
134impl Bc5dDownloader {
135    /// Create a new downloader
136    ///
137    /// # Arguments
138    /// * `base_url` - Base URL for BC5D table downloads
139    /// * `force_refresh` - If true, always re-download even if cached
140    ///
141    /// # Returns
142    /// A new downloader, or an error if the cache directory cannot be created
143    pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
144        let cache_dir = get_cache_directory()?;
145
146        // Create cache directory if it doesn't exist
147        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    /// Ensure a BC5D table is available for the given caliber
166    ///
167    /// Downloads the table if not cached or if force_refresh is true.
168    ///
169    /// # Arguments
170    /// * `caliber` - Bullet caliber in inches (e.g., 0.308)
171    ///
172    /// # Returns
173    /// Path to the cached table file
174    pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
175        // Load manifest if not already loaded
176        if self.manifest.is_none() {
177            self.manifest = Some(self.fetch_manifest()?);
178        }
179        let manifest = self.manifest.as_ref().unwrap();
180
181        // Convert caliber to key (e.g., 0.308 -> "308")
182        let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
183
184        // Check if caliber is available
185        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        // Check if already cached and valid
193        let cached_path = self.cache_dir.join(&entry.file);
194        if !self.force_refresh && cached_path.exists() {
195            // Verify checksum
196            if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
197                if actual_crc == entry.crc32 {
198                    return Ok(cached_path);
199                }
200                // Checksum mismatch - re-download
201                eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
202            }
203        }
204
205        // Download the table
206        self.download_table(&entry.file, &cached_path, &entry.crc32)?;
207
208        Ok(cached_path)
209    }
210
211    /// Get list of available calibers from the server
212    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    /// Get the cache directory path
220    pub fn cache_dir(&self) -> &PathBuf {
221        &self.cache_dir
222    }
223
224    /// Check if a table is cached for the given caliber
225    pub fn is_cached(&self, caliber: f64) -> bool {
226        let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
227        // Resolve the filename the same way ensure_table does — prefer the manifest
228        // entry's `file` when a manifest is loaded — so is_cached and the real cache
229        // path cannot disagree. Falls back to the default name when no manifest is present.
230        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    /// Get available calibers from a manifest
240    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    /// Fetch the manifest from the server
252    #[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        // Parse manifest
276        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    /// Stub for non-online builds
321    #[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    /// Download a table file
329    #[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        // Read response body. `into_body().into_reader()` reads without the 10MB limit
356        // imposed by the convenience `read_to_*` helpers, matching the prior behavior.
357        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        // Verify checksum
367        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        // Write to file
376        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    /// Stub for non-online builds
385    #[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
398/// Get the platform-specific cache directory for BC5D tables
399pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
400    // Try to use the dirs crate for platform-specific directories
401    if let Some(cache_dir) = dirs::cache_dir() {
402        return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
403    }
404
405    // Fallback to home directory
406    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
431/// Calculate CRC32 of a byte slice
432fn 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
442/// Calculate CRC32 of a file
443fn 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
450/// Generate CRC32 lookup table (IEEE polynomial)
451const 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        // Test with known value
479        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        // Test caliber to key conversion
495        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}