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!(f, "Checksum mismatch: expected {}, got {}", expected, actual)
61            }
62            Bc5dDownloadError::CaliberNotAvailable { requested, available } => {
63                let available_str: Vec<String> = available.iter().map(|c| format!(".{}", (c * 1000.0) as i32)).collect();
64                write!(
65                    f,
66                    "No BC5D table available for caliber {:.3} ({:.1}mm)\nAvailable calibers: {}",
67                    requested,
68                    requested * 25.4,
69                    available_str.join(", ")
70                )
71            }
72            Bc5dDownloadError::ManifestParseError(msg) => write!(f, "Manifest parse error: {}", msg),
73            Bc5dDownloadError::CacheDirectoryError(msg) => write!(f, "Cache directory error: {}", msg),
74        }
75    }
76}
77
78impl std::error::Error for Bc5dDownloadError {}
79
80impl From<std::io::Error> for Bc5dDownloadError {
81    fn from(e: std::io::Error) -> Self {
82        Bc5dDownloadError::IoError(e)
83    }
84}
85
86/// Manifest entry for a BC5D table
87#[derive(Debug, Clone)]
88pub struct TableEntry {
89    /// Filename
90    pub file: String,
91    /// File size in bytes
92    pub size: u64,
93    /// CRC32 checksum (hex string)
94    pub crc32: String,
95}
96
97/// Manifest describing available BC5D tables
98#[derive(Debug, Clone)]
99pub struct Bc5dManifest {
100    /// Manifest version
101    pub version: String,
102    /// When the manifest was generated
103    pub generated: String,
104    /// Map of caliber (as string like "308") to table entry
105    pub tables: std::collections::HashMap<String, TableEntry>,
106}
107
108/// BC5D table downloader with caching
109pub struct Bc5dDownloader {
110    /// Base URL for downloads
111    base_url: String,
112    /// Local cache directory
113    cache_dir: PathBuf,
114    /// Force re-download even if cached
115    force_refresh: bool,
116    /// Cached manifest
117    manifest: Option<Bc5dManifest>,
118}
119
120impl Bc5dDownloader {
121    /// Create a new downloader
122    ///
123    /// # Arguments
124    /// * `base_url` - Base URL for BC5D table downloads
125    /// * `force_refresh` - If true, always re-download even if cached
126    ///
127    /// # Returns
128    /// A new downloader, or an error if the cache directory cannot be created
129    pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
130        let cache_dir = get_cache_directory()?;
131
132        // Create cache directory if it doesn't exist
133        if !cache_dir.exists() {
134            fs::create_dir_all(&cache_dir).map_err(|e| {
135                Bc5dDownloadError::CacheDirectoryError(format!(
136                    "Failed to create cache directory {}: {}",
137                    cache_dir.display(),
138                    e
139                ))
140            })?;
141        }
142
143        Ok(Bc5dDownloader {
144            base_url: base_url.trim_end_matches('/').to_string(),
145            cache_dir,
146            force_refresh,
147            manifest: None,
148        })
149    }
150
151    /// Ensure a BC5D table is available for the given caliber
152    ///
153    /// Downloads the table if not cached or if force_refresh is true.
154    ///
155    /// # Arguments
156    /// * `caliber` - Bullet caliber in inches (e.g., 0.308)
157    ///
158    /// # Returns
159    /// Path to the cached table file
160    pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
161        // Load manifest if not already loaded
162        if self.manifest.is_none() {
163            self.manifest = Some(self.fetch_manifest()?);
164        }
165        let manifest = self.manifest.as_ref().unwrap();
166
167        // Convert caliber to key (e.g., 0.308 -> "308")
168        let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
169
170        // Check if caliber is available
171        let entry = manifest.tables.get(&caliber_key).ok_or_else(|| {
172            Bc5dDownloadError::CaliberNotAvailable {
173                requested: caliber,
174                available: self.available_calibers_from_manifest(manifest),
175            }
176        })?;
177
178        // Check if already cached and valid
179        let cached_path = self.cache_dir.join(&entry.file);
180        if !self.force_refresh && cached_path.exists() {
181            // Verify checksum
182            if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
183                if actual_crc == entry.crc32 {
184                    return Ok(cached_path);
185                }
186                // Checksum mismatch - re-download
187                eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
188            }
189        }
190
191        // Download the table
192        self.download_table(&entry.file, &cached_path, &entry.crc32)?;
193
194        Ok(cached_path)
195    }
196
197    /// Get list of available calibers from the server
198    pub fn available_calibers(&mut self) -> Result<Vec<f64>, Bc5dDownloadError> {
199        if self.manifest.is_none() {
200            self.manifest = Some(self.fetch_manifest()?);
201        }
202        Ok(self.available_calibers_from_manifest(self.manifest.as_ref().unwrap()))
203    }
204
205    /// Get the cache directory path
206    pub fn cache_dir(&self) -> &PathBuf {
207        &self.cache_dir
208    }
209
210    /// Check if a table is cached for the given caliber
211    pub fn is_cached(&self, caliber: f64) -> bool {
212        let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
213        // Resolve the filename the same way ensure_table does — prefer the manifest
214        // entry's `file` when a manifest is loaded — so is_cached and the real cache
215        // path cannot disagree. Falls back to the default name when no manifest is present.
216        let filename = self
217            .manifest
218            .as_ref()
219            .and_then(|m| m.tables.get(&caliber_key))
220            .map(|entry| entry.file.clone())
221            .unwrap_or_else(|| format!("bc5d_{}.bin", caliber_key));
222        self.cache_dir.join(&filename).exists()
223    }
224
225    /// Get available calibers from a manifest
226    fn available_calibers_from_manifest(&self, manifest: &Bc5dManifest) -> Vec<f64> {
227        let mut calibers: Vec<f64> = manifest
228            .tables
229            .keys()
230            .filter_map(|k| k.parse::<i32>().ok())
231            .map(|k| k as f64 / 1000.0)
232            .collect();
233        calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
234        calibers
235    }
236
237    /// Fetch the manifest from the server
238    #[cfg(feature = "online")]
239    fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
240        let url = format!("{}/{}", self.base_url, MANIFEST_FILE);
241
242        let mut response = ureq::get(&url)
243            .config()
244            .timeout_global(Some(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)))
245            .build()
246            .call()
247            .map_err(|e| match e {
248                ureq::Error::Io(io_err) => {
249                    Bc5dDownloadError::NetworkError(format!("Connection failed: {}", io_err))
250                }
251                ureq::Error::Timeout(_) => {
252                    Bc5dDownloadError::NetworkError("Connection failed: timed out".to_string())
253                }
254                _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
255            })?;
256
257        let json: serde_json::Value = response.body_mut().read_json().map_err(|e| {
258            Bc5dDownloadError::ManifestParseError(format!("Failed to parse JSON: {}", e))
259        })?;
260
261        // Parse manifest
262        let version = json["version"]
263            .as_str()
264            .unwrap_or("unknown")
265            .to_string();
266        let generated = json["generated"]
267            .as_str()
268            .unwrap_or("unknown")
269            .to_string();
270
271        let tables_obj = json["tables"]
272            .as_object()
273            .ok_or_else(|| Bc5dDownloadError::ManifestParseError("Missing 'tables' field".to_string()))?;
274
275        let mut tables = std::collections::HashMap::new();
276        for (caliber, entry) in tables_obj {
277            let file = entry["file"]
278                .as_str()
279                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'file' for caliber {}", caliber)))?
280                .to_string();
281            let size = entry["size"]
282                .as_u64()
283                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'size' for caliber {}", caliber)))?;
284            let crc32 = entry["crc32"]
285                .as_str()
286                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'crc32' for caliber {}", caliber)))?
287                .to_string();
288
289            tables.insert(caliber.clone(), TableEntry { file, size, crc32 });
290        }
291
292        Ok(Bc5dManifest {
293            version,
294            generated,
295            tables,
296        })
297    }
298
299    /// Stub for non-online builds
300    #[cfg(not(feature = "online"))]
301    fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
302        Err(Bc5dDownloadError::NetworkError(
303            "Online features not enabled. Build with --features online".to_string(),
304        ))
305    }
306
307    /// Download a table file
308    #[cfg(feature = "online")]
309    fn download_table(&self, filename: &str, dest_path: &PathBuf, expected_crc: &str) -> Result<(), Bc5dDownloadError> {
310        let url = format!("{}/{}", self.base_url, filename);
311
312        eprintln!("Downloading BC5D table: {}...", filename);
313
314        let response = ureq::get(&url)
315            .config()
316            .timeout_global(Some(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)))
317            .build()
318            .call()
319            .map_err(|e| match e {
320                ureq::Error::Io(io_err) => {
321                    Bc5dDownloadError::NetworkError(format!("Connection failed: {}", io_err))
322                }
323                ureq::Error::Timeout(_) => {
324                    Bc5dDownloadError::NetworkError("Connection failed: timed out".to_string())
325                }
326                _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
327            })?;
328
329        // Read response body. `into_body().into_reader()` reads without the 10MB limit
330        // imposed by the convenience `read_to_*` helpers, matching the prior behavior.
331        let mut data = Vec::new();
332        response
333            .into_body()
334            .into_reader()
335            .read_to_end(&mut data)
336            .map_err(|e| {
337                Bc5dDownloadError::NetworkError(format!("Failed to read response: {}", e))
338            })?;
339
340        // Verify checksum
341        let actual_crc = calculate_crc32(&data);
342        if actual_crc != expected_crc {
343            return Err(Bc5dDownloadError::ChecksumMismatch {
344                expected: expected_crc.to_string(),
345                actual: actual_crc,
346            });
347        }
348
349        // Write to file
350        let mut file = File::create(dest_path)?;
351        file.write_all(&data)?;
352
353        eprintln!("Downloaded {} ({} bytes)", filename, data.len());
354
355        Ok(())
356    }
357
358    /// Stub for non-online builds
359    #[cfg(not(feature = "online"))]
360    fn download_table(&self, _filename: &str, _dest_path: &PathBuf, _expected_crc: &str) -> Result<(), Bc5dDownloadError> {
361        Err(Bc5dDownloadError::NetworkError(
362            "Online features not enabled. Build with --features online".to_string(),
363        ))
364    }
365}
366
367/// Get the platform-specific cache directory for BC5D tables
368pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
369    // Try to use the dirs crate for platform-specific directories
370    if let Some(cache_dir) = dirs::cache_dir() {
371        return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
372    }
373
374    // Fallback to home directory
375    if let Some(home) = dirs::home_dir() {
376        #[cfg(target_os = "macos")]
377        return Ok(home.join("Library").join("Caches").join("ballistics-engine").join("bc5d"));
378
379        #[cfg(target_os = "windows")]
380        return Ok(home.join("AppData").join("Local").join("ballistics-engine").join("cache").join("bc5d"));
381
382        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
383        return Ok(home.join(".cache").join("ballistics-engine").join("bc5d"));
384    }
385
386    Err(Bc5dDownloadError::CacheDirectoryError(
387        "Could not determine cache directory".to_string(),
388    ))
389}
390
391/// Calculate CRC32 of a byte slice
392fn calculate_crc32(data: &[u8]) -> String {
393    const TABLE: [u32; 256] = make_crc32_table();
394    let mut crc = 0xFFFFFFFFu32;
395    for &byte in data {
396        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
397        crc = (crc >> 8) ^ TABLE[idx];
398    }
399    format!("{:08x}", !crc)
400}
401
402/// Calculate CRC32 of a file
403fn calculate_file_crc32(path: &PathBuf) -> Result<String, std::io::Error> {
404    let mut file = File::open(path)?;
405    let mut data = Vec::new();
406    file.read_to_end(&mut data)?;
407    Ok(calculate_crc32(&data))
408}
409
410/// Generate CRC32 lookup table (IEEE polynomial)
411const fn make_crc32_table() -> [u32; 256] {
412    const POLY: u32 = 0xEDB88320;
413    let mut table = [0u32; 256];
414    let mut i = 0;
415    while i < 256 {
416        let mut crc = i as u32;
417        let mut j = 0;
418        while j < 8 {
419            if crc & 1 != 0 {
420                crc = (crc >> 1) ^ POLY;
421            } else {
422                crc >>= 1;
423            }
424            j += 1;
425        }
426        table[i] = crc;
427        i += 1;
428    }
429    table
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_crc32_calculation() {
438        // Test with known value
439        let data = b"123456789";
440        let crc = calculate_crc32(data);
441        assert_eq!(crc, "cbf43926");
442    }
443
444    #[test]
445    fn test_cache_directory() {
446        let cache_dir = get_cache_directory();
447        assert!(cache_dir.is_ok());
448        let path = cache_dir.unwrap();
449        assert!(path.to_string_lossy().contains("bc5d"));
450    }
451
452    #[test]
453    fn test_caliber_key_conversion() {
454        // Test caliber to key conversion
455        let caliber: f64 = 0.308;
456        let key = format!("{}", (caliber * 1000.0).round() as i32);
457        assert_eq!(key, "308");
458
459        let caliber: f64 = 0.224;
460        let key = format!("{}", (caliber * 1000.0).round() as i32);
461        assert_eq!(key, "224");
462    }
463
464    #[test]
465    fn test_error_display() {
466        let err = Bc5dDownloadError::CaliberNotAvailable {
467            requested: 0.375,
468            available: vec![0.224, 0.308, 0.338],
469        };
470        let msg = format!("{}", err);
471        assert!(msg.contains("0.375"));
472        assert!(msg.contains("9.5mm"));
473        assert!(msg.contains(".224"));
474        assert!(msg.contains(".308"));
475        assert!(msg.contains(".338"));
476    }
477}