use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use std::time::Duration;
pub const DEFAULT_BC5D_URL: &str = "https://ballistics.tools/downloads/bc5d";
const DOWNLOAD_TIMEOUT_SECS: u64 = 60;
const MANIFEST_FILE: &str = "manifest.json";
#[derive(Debug)]
pub enum Bc5dDownloadError {
NetworkError(String),
Timeout,
IoError(std::io::Error),
ChecksumMismatch { expected: String, actual: String },
CaliberNotAvailable { requested: f64, available: Vec<f64> },
ManifestParseError(String),
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)
}
}
#[derive(Debug, Clone)]
pub struct TableEntry {
pub file: String,
pub size: u64,
pub crc32: String,
}
#[derive(Debug, Clone)]
pub struct Bc5dManifest {
pub version: String,
pub generated: String,
pub tables: std::collections::HashMap<String, TableEntry>,
}
pub struct Bc5dDownloader {
base_url: String,
cache_dir: PathBuf,
force_refresh: bool,
manifest: Option<Bc5dManifest>,
}
impl Bc5dDownloader {
pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
let cache_dir = get_cache_directory()?;
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,
})
}
pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
if self.manifest.is_none() {
self.manifest = Some(self.fetch_manifest()?);
}
let manifest = self.manifest.as_ref().unwrap();
let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
let entry = manifest.tables.get(&caliber_key).ok_or_else(|| {
Bc5dDownloadError::CaliberNotAvailable {
requested: caliber,
available: self.available_calibers_from_manifest(manifest),
}
})?;
let cached_path = self.cache_dir.join(&entry.file);
if !self.force_refresh && cached_path.exists() {
if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
if actual_crc == entry.crc32 {
return Ok(cached_path);
}
eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
}
}
self.download_table(&entry.file, &cached_path, &entry.crc32)?;
Ok(cached_path)
}
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()))
}
pub fn cache_dir(&self) -> &PathBuf {
&self.cache_dir
}
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()
}
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
}
#[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))
})?;
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,
})
}
#[cfg(not(feature = "online"))]
fn fetch_manifest(&self) -> Result<Bc5dManifest, Bc5dDownloadError> {
Err(Bc5dDownloadError::NetworkError(
"Online features not enabled. Build with --features online".to_string(),
))
}
#[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)),
})?;
let mut data = Vec::new();
response.into_reader().read_to_end(&mut data).map_err(|e| {
Bc5dDownloadError::NetworkError(format!("Failed to read response: {}", e))
})?;
let actual_crc = calculate_crc32(&data);
if actual_crc != expected_crc {
return Err(Bc5dDownloadError::ChecksumMismatch {
expected: expected_crc.to_string(),
actual: actual_crc,
});
}
let mut file = File::create(dest_path)?;
file.write_all(&data)?;
eprintln!("Downloaded {} ({} bytes)", filename, data.len());
Ok(())
}
#[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(),
))
}
}
pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
if let Some(cache_dir) = dirs::cache_dir() {
return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
}
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(),
))
}
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)
}
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))
}
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() {
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() {
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"));
}
}