ballistics-engine 0.14.1

High-performance ballistics trajectory engine with professional physics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! BC5D Table Auto-Download Module
//!
//! This module provides automatic downloading and caching of BC5D correction tables
//! from a remote server. Tables are cached locally to avoid repeated downloads.
//!
//! # Example
//!
//! ```no_run
//! use ballistics_engine::bc_table_download::Bc5dDownloader;
//!
//! let mut downloader = Bc5dDownloader::new(
//!     "https://ballistics.tools/downloads/bc5d",
//!     false
//! ).unwrap();
//!
//! // Download table for .308 caliber
//! let table_path = downloader.ensure_table(0.308).unwrap();
//! ```

use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::time::Duration;

/// Default URL for BC5D table downloads
pub const DEFAULT_BC5D_URL: &str = "https://ballistics.tools/downloads/bc5d";

/// Download timeout in seconds
const DOWNLOAD_TIMEOUT_SECS: u64 = 60;

/// Manifest file name
const MANIFEST_FILE: &str = "manifest.json";

/// Error type for BC5D table download operations
#[derive(Debug)]
pub enum Bc5dDownloadError {
    /// Network error during download
    NetworkError(String),
    /// Request timed out
    Timeout,
    /// IO error reading/writing files
    IoError(std::io::Error),
    /// CRC32 checksum mismatch after download
    ChecksumMismatch { expected: String, actual: String },
    /// Requested caliber not available
    CaliberNotAvailable { requested: f64, available: Vec<f64> },
    /// Failed to parse manifest
    ManifestParseError(String),
    /// Cache directory could not be created
    CacheDirectoryError(String),
}

impl std::fmt::Display for Bc5dDownloadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Bc5dDownloadError::NetworkError(msg) => write!(f, "Network error: {}", msg),
            Bc5dDownloadError::Timeout => write!(f, "Download timed out"),
            Bc5dDownloadError::IoError(e) => write!(f, "IO error: {}", e),
            Bc5dDownloadError::ChecksumMismatch { expected, actual } => {
                write!(f, "Checksum mismatch: expected {}, got {}", expected, actual)
            }
            Bc5dDownloadError::CaliberNotAvailable { requested, available } => {
                let available_str: Vec<String> = available.iter().map(|c| format!(".{}", (c * 1000.0) as i32)).collect();
                write!(
                    f,
                    "No BC5D table available for caliber {:.3} ({:.1}mm)\nAvailable calibers: {}",
                    requested,
                    requested * 25.4,
                    available_str.join(", ")
                )
            }
            Bc5dDownloadError::ManifestParseError(msg) => write!(f, "Manifest parse error: {}", msg),
            Bc5dDownloadError::CacheDirectoryError(msg) => write!(f, "Cache directory error: {}", msg),
        }
    }
}

impl std::error::Error for Bc5dDownloadError {}

impl From<std::io::Error> for Bc5dDownloadError {
    fn from(e: std::io::Error) -> Self {
        Bc5dDownloadError::IoError(e)
    }
}

/// Manifest entry for a BC5D table
#[derive(Debug, Clone)]
pub struct TableEntry {
    /// Filename
    pub file: String,
    /// File size in bytes
    pub size: u64,
    /// CRC32 checksum (hex string)
    pub crc32: String,
}

/// Manifest describing available BC5D tables
#[derive(Debug, Clone)]
pub struct Bc5dManifest {
    /// Manifest version
    pub version: String,
    /// When the manifest was generated
    pub generated: String,
    /// Map of caliber (as string like "308") to table entry
    pub tables: std::collections::HashMap<String, TableEntry>,
}

/// BC5D table downloader with caching
pub struct Bc5dDownloader {
    /// Base URL for downloads
    base_url: String,
    /// Local cache directory
    cache_dir: PathBuf,
    /// Force re-download even if cached
    force_refresh: bool,
    /// Cached manifest
    manifest: Option<Bc5dManifest>,
}

impl Bc5dDownloader {
    /// Create a new downloader
    ///
    /// # Arguments
    /// * `base_url` - Base URL for BC5D table downloads
    /// * `force_refresh` - If true, always re-download even if cached
    ///
    /// # Returns
    /// A new downloader, or an error if the cache directory cannot be created
    pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
        let cache_dir = get_cache_directory()?;

        // Create cache directory if it doesn't exist
        if !cache_dir.exists() {
            fs::create_dir_all(&cache_dir).map_err(|e| {
                Bc5dDownloadError::CacheDirectoryError(format!(
                    "Failed to create cache directory {}: {}",
                    cache_dir.display(),
                    e
                ))
            })?;
        }

        Ok(Bc5dDownloader {
            base_url: base_url.trim_end_matches('/').to_string(),
            cache_dir,
            force_refresh,
            manifest: None,
        })
    }

    /// Ensure a BC5D table is available for the given caliber
    ///
    /// Downloads the table if not cached or if force_refresh is true.
    ///
    /// # Arguments
    /// * `caliber` - Bullet caliber in inches (e.g., 0.308)
    ///
    /// # Returns
    /// Path to the cached table file
    pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
        // Load manifest if not already loaded
        if self.manifest.is_none() {
            self.manifest = Some(self.fetch_manifest()?);
        }
        let manifest = self.manifest.as_ref().unwrap();

        // Convert caliber to key (e.g., 0.308 -> "308")
        let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);

        // Check if caliber is available
        let entry = manifest.tables.get(&caliber_key).ok_or_else(|| {
            Bc5dDownloadError::CaliberNotAvailable {
                requested: caliber,
                available: self.available_calibers_from_manifest(manifest),
            }
        })?;

        // Check if already cached and valid
        let cached_path = self.cache_dir.join(&entry.file);
        if !self.force_refresh && cached_path.exists() {
            // Verify checksum
            if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
                if actual_crc == entry.crc32 {
                    return Ok(cached_path);
                }
                // Checksum mismatch - re-download
                eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
            }
        }

        // Download the table
        self.download_table(&entry.file, &cached_path, &entry.crc32)?;

        Ok(cached_path)
    }

    /// Get list of available calibers from the server
    pub fn available_calibers(&mut self) -> Result<Vec<f64>, Bc5dDownloadError> {
        if self.manifest.is_none() {
            self.manifest = Some(self.fetch_manifest()?);
        }
        Ok(self.available_calibers_from_manifest(self.manifest.as_ref().unwrap()))
    }

    /// Get the cache directory path
    pub fn cache_dir(&self) -> &PathBuf {
        &self.cache_dir
    }

    /// Check if a table is cached for the given caliber
    pub fn is_cached(&self, caliber: f64) -> bool {
        let caliber_key = (caliber * 1000.0).round() as i32;
        let filename = format!("bc5d_{}.bin", caliber_key);
        self.cache_dir.join(&filename).exists()
    }

    /// Get available calibers from a manifest
    fn available_calibers_from_manifest(&self, manifest: &Bc5dManifest) -> Vec<f64> {
        let mut calibers: Vec<f64> = manifest
            .tables
            .keys()
            .filter_map(|k| k.parse::<i32>().ok())
            .map(|k| k as f64 / 1000.0)
            .collect();
        calibers.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        calibers
    }

    /// Fetch the manifest from the server
    #[cfg(feature = "online")]
    fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
        let url = format!("{}/{}", self.base_url, MANIFEST_FILE);

        let response = ureq::get(&url)
            .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
            .call()
            .map_err(|e| match e {
                ureq::Error::Transport(t) if t.kind() == ureq::ErrorKind::Io => {
                    Bc5dDownloadError::NetworkError(format!("Connection failed: {}", t))
                }
                _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
            })?;

        let json: serde_json::Value = response.into_json().map_err(|e| {
            Bc5dDownloadError::ManifestParseError(format!("Failed to parse JSON: {}", e))
        })?;

        // Parse manifest
        let version = json["version"]
            .as_str()
            .unwrap_or("unknown")
            .to_string();
        let generated = json["generated"]
            .as_str()
            .unwrap_or("unknown")
            .to_string();

        let tables_obj = json["tables"]
            .as_object()
            .ok_or_else(|| Bc5dDownloadError::ManifestParseError("Missing 'tables' field".to_string()))?;

        let mut tables = std::collections::HashMap::new();
        for (caliber, entry) in tables_obj {
            let file = entry["file"]
                .as_str()
                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'file' for caliber {}", caliber)))?
                .to_string();
            let size = entry["size"]
                .as_u64()
                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'size' for caliber {}", caliber)))?;
            let crc32 = entry["crc32"]
                .as_str()
                .ok_or_else(|| Bc5dDownloadError::ManifestParseError(format!("Missing 'crc32' for caliber {}", caliber)))?
                .to_string();

            tables.insert(caliber.clone(), TableEntry { file, size, crc32 });
        }

        Ok(Bc5dManifest {
            version,
            generated,
            tables,
        })
    }

    /// Stub for non-online builds
    #[cfg(not(feature = "online"))]
    fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
        Err(Bc5dDownloadError::NetworkError(
            "Online features not enabled. Build with --features online".to_string(),
        ))
    }

    /// Download a table file
    #[cfg(feature = "online")]
    fn download_table(&self, filename: &str, dest_path: &PathBuf, expected_crc: &str) -> Result<(), Bc5dDownloadError> {
        let url = format!("{}/{}", self.base_url, filename);

        eprintln!("Downloading BC5D table: {}...", filename);

        let response = ureq::get(&url)
            .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
            .call()
            .map_err(|e| match e {
                ureq::Error::Transport(t) if t.kind() == ureq::ErrorKind::Io => {
                    Bc5dDownloadError::NetworkError(format!("Connection failed: {}", t))
                }
                _ => Bc5dDownloadError::NetworkError(format!("{}", e)),
            })?;

        // Read response body
        let mut data = Vec::new();
        response.into_reader().read_to_end(&mut data).map_err(|e| {
            Bc5dDownloadError::NetworkError(format!("Failed to read response: {}", e))
        })?;

        // Verify checksum
        let actual_crc = calculate_crc32(&data);
        if actual_crc != expected_crc {
            return Err(Bc5dDownloadError::ChecksumMismatch {
                expected: expected_crc.to_string(),
                actual: actual_crc,
            });
        }

        // Write to file
        let mut file = File::create(dest_path)?;
        file.write_all(&data)?;

        eprintln!("Downloaded {} ({} bytes)", filename, data.len());

        Ok(())
    }

    /// Stub for non-online builds
    #[cfg(not(feature = "online"))]
    fn download_table(&self, _filename: &str, _dest_path: &PathBuf, _expected_crc: &str) -> Result<(), Bc5dDownloadError> {
        Err(Bc5dDownloadError::NetworkError(
            "Online features not enabled. Build with --features online".to_string(),
        ))
    }
}

/// Get the platform-specific cache directory for BC5D tables
pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
    // Try to use the dirs crate for platform-specific directories
    if let Some(cache_dir) = dirs::cache_dir() {
        return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
    }

    // Fallback to home directory
    if let Some(home) = dirs::home_dir() {
        #[cfg(target_os = "macos")]
        return Ok(home.join("Library").join("Caches").join("ballistics-engine").join("bc5d"));

        #[cfg(target_os = "windows")]
        return Ok(home.join("AppData").join("Local").join("ballistics-engine").join("cache").join("bc5d"));

        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
        return Ok(home.join(".cache").join("ballistics-engine").join("bc5d"));
    }

    Err(Bc5dDownloadError::CacheDirectoryError(
        "Could not determine cache directory".to_string(),
    ))
}

/// Calculate CRC32 of a byte slice
fn calculate_crc32(data: &[u8]) -> String {
    const TABLE: [u32; 256] = make_crc32_table();
    let mut crc = 0xFFFFFFFFu32;
    for &byte in data {
        let idx = ((crc ^ byte as u32) & 0xFF) as usize;
        crc = (crc >> 8) ^ TABLE[idx];
    }
    format!("{:08x}", !crc)
}

/// Calculate CRC32 of a file
fn calculate_file_crc32(path: &PathBuf) -> Result<String, std::io::Error> {
    let mut file = File::open(path)?;
    let mut data = Vec::new();
    file.read_to_end(&mut data)?;
    Ok(calculate_crc32(&data))
}

/// Generate CRC32 lookup table (IEEE polynomial)
const fn make_crc32_table() -> [u32; 256] {
    const POLY: u32 = 0xEDB88320;
    let mut table = [0u32; 256];
    let mut i = 0;
    while i < 256 {
        let mut crc = i as u32;
        let mut j = 0;
        while j < 8 {
            if crc & 1 != 0 {
                crc = (crc >> 1) ^ POLY;
            } else {
                crc >>= 1;
            }
            j += 1;
        }
        table[i] = crc;
        i += 1;
    }
    table
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_crc32_calculation() {
        // Test with known value
        let data = b"123456789";
        let crc = calculate_crc32(data);
        assert_eq!(crc, "cbf43926");
    }

    #[test]
    fn test_cache_directory() {
        let cache_dir = get_cache_directory();
        assert!(cache_dir.is_ok());
        let path = cache_dir.unwrap();
        assert!(path.to_string_lossy().contains("bc5d"));
    }

    #[test]
    fn test_caliber_key_conversion() {
        // Test caliber to key conversion
        let caliber: f64 = 0.308;
        let key = format!("{}", (caliber * 1000.0).round() as i32);
        assert_eq!(key, "308");

        let caliber: f64 = 0.224;
        let key = format!("{}", (caliber * 1000.0).round() as i32);
        assert_eq!(key, "224");
    }

    #[test]
    fn test_error_display() {
        let err = Bc5dDownloadError::CaliberNotAvailable {
            requested: 0.375,
            available: vec![0.224, 0.308, 0.338],
        };
        let msg = format!("{}", err);
        assert!(msg.contains("0.375"));
        assert!(msg.contains("9.5mm"));
        assert!(msg.contains(".224"));
        assert!(msg.contains(".308"));
        assert!(msg.contains(".338"));
    }
}