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!(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#[derive(Debug, Clone)]
88pub struct TableEntry {
89 pub file: String,
91 pub size: u64,
93 pub crc32: String,
95}
96
97#[derive(Debug, Clone)]
99pub struct Bc5dManifest {
100 pub version: String,
102 pub generated: String,
104 pub tables: std::collections::HashMap<String, TableEntry>,
106}
107
108pub struct Bc5dDownloader {
110 base_url: String,
112 cache_dir: PathBuf,
114 force_refresh: bool,
116 manifest: Option<Bc5dManifest>,
118}
119
120impl Bc5dDownloader {
121 pub fn new(base_url: &str, force_refresh: bool) -> Result<Self, Bc5dDownloadError> {
130 let cache_dir = get_cache_directory()?;
131
132 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 pub fn ensure_table(&mut self, caliber: f64) -> Result<PathBuf, Bc5dDownloadError> {
161 if self.manifest.is_none() {
163 self.manifest = Some(self.fetch_manifest()?);
164 }
165 let manifest = self.manifest.as_ref().unwrap();
166
167 let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
169
170 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 let cached_path = self.cache_dir.join(&entry.file);
180 if !self.force_refresh && cached_path.exists() {
181 if let Ok(actual_crc) = calculate_file_crc32(&cached_path) {
183 if actual_crc == entry.crc32 {
184 return Ok(cached_path);
185 }
186 eprintln!("Warning: Cached table checksum mismatch, re-downloading...");
188 }
189 }
190
191 self.download_table(&entry.file, &cached_path, &entry.crc32)?;
193
194 Ok(cached_path)
195 }
196
197 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 pub fn cache_dir(&self) -> &PathBuf {
207 &self.cache_dir
208 }
209
210 pub fn is_cached(&self, caliber: f64) -> bool {
212 let caliber_key = format!("{}", (caliber * 1000.0).round() as i32);
213 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 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 #[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 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 #[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 #[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 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 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 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 #[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
367pub fn get_cache_directory() -> Result<PathBuf, Bc5dDownloadError> {
369 if let Some(cache_dir) = dirs::cache_dir() {
371 return Ok(cache_dir.join("ballistics-engine").join("bc5d"));
372 }
373
374 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
391fn 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
402fn 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
410const 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 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 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}