1use std::fmt;
39use std::fs::{self, File};
40use std::io::{self, BufWriter, Read, Write};
41use std::path::{Path, PathBuf};
42
43use serde::{Deserialize, Serialize};
44
45struct KernelEntry {
48 filename: &'static str,
49 url: &'static str,
50 gzipped: bool,
51 is_static: bool,
52}
53
54const DEFAULT_KERNELS: &[KernelEntry] = &[
55 KernelEntry {
56 filename: "de440.bsp",
57 url: "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440.bsp",
58 gzipped: false,
59 is_static: true,
60 },
61 KernelEntry {
62 filename: "sb441-n16.bsp",
63 url: "https://ssd.jpl.nasa.gov/ftp/eph/small_bodies/asteroids_de441/sb441-n16.bsp",
64 gzipped: false,
65 is_static: true,
66 },
67 KernelEntry {
68 filename: "obscodes_extended.json",
69 url: "https://minorplanetcenter.net/Extended_Files/obscodes_extended.json.gz",
70 gzipped: true,
71 is_static: false,
72 },
73 KernelEntry {
78 filename: "earth_latest_high_prec.bpc",
79 url: "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/earth_latest_high_prec.bpc",
80 gzipped: false,
81 is_static: false,
82 },
83 KernelEntry {
84 filename: "earth_620120_250826.bpc",
85 url: "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/earth_620120_250826.bpc",
86 gzipped: false,
87 is_static: true,
88 },
89 KernelEntry {
90 filename: "earth_2025_250826_2125_predict.bpc",
91 url: "https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/earth_2025_250826_2125_predict.bpc",
92 gzipped: false,
93 is_static: true,
94 },
95];
96
97#[derive(Debug, Serialize, Deserialize)]
100struct FileMeta {
101 url: String,
102 downloaded_at: u64,
103 content_length: Option<u64>,
104 last_modified: Option<String>,
105 md5: String,
106}
107
108#[derive(Debug, Clone)]
112pub struct AssistDataPaths {
113 pub planets: PathBuf,
115 pub asteroids: PathBuf,
117 pub obscodes: PathBuf,
119 pub eop_high_prec: PathBuf,
122 pub eop_historical: PathBuf,
124 pub eop_predict: PathBuf,
126}
127
128impl AssistDataPaths {
129 pub fn eop_kernels(&self) -> [&PathBuf; 3] {
134 [&self.eop_predict, &self.eop_historical, &self.eop_high_prec]
135 }
136}
137
138#[derive(Debug)]
142pub enum DataError {
143 MissingFiles(Vec<String>),
145 Http(String),
147 Io(io::Error),
149}
150
151impl fmt::Display for DataError {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 match self {
154 Self::MissingFiles(files) => write!(f, "missing data files: {}", files.join(", ")),
155 Self::Http(msg) => write!(f, "HTTP error: {msg}"),
156 Self::Io(e) => write!(f, "I/O error: {e}"),
157 }
158 }
159}
160
161impl std::error::Error for DataError {}
162
163pub struct DataManager {
171 data_dir: PathBuf,
172}
173
174impl Default for DataManager {
175 fn default() -> Self {
176 Self::new()
177 }
178}
179
180impl DataManager {
181 pub fn new() -> Self {
183 let data_dir = std::env::var("ASSIST_DATA_DIR")
184 .map(PathBuf::from)
185 .unwrap_or_else(|_| {
186 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
187 PathBuf::from(xdg).join("assist-rs")
188 } else {
189 let home = std::env::var("HOME").expect("HOME environment variable not set");
190 PathBuf::from(home).join(".cache").join("assist-rs")
191 }
192 });
193 Self { data_dir }
194 }
195
196 pub fn with_dir(dir: impl Into<PathBuf>) -> Self {
198 Self {
199 data_dir: dir.into(),
200 }
201 }
202
203 pub fn data_dir(&self) -> &Path {
205 &self.data_dir
206 }
207
208 fn paths(&self) -> AssistDataPaths {
209 AssistDataPaths {
210 planets: self.data_dir.join("de440.bsp"),
211 asteroids: self.data_dir.join("sb441-n16.bsp"),
212 obscodes: self.data_dir.join("obscodes_extended.json"),
213 eop_high_prec: self.data_dir.join("earth_latest_high_prec.bpc"),
214 eop_historical: self.data_dir.join("earth_620120_250826.bpc"),
215 eop_predict: self.data_dir.join("earth_2025_250826_2125_predict.bpc"),
216 }
217 }
218
219 pub fn offline(&self) -> Result<AssistDataPaths, DataError> {
221 let missing: Vec<String> = DEFAULT_KERNELS
222 .iter()
223 .filter(|e| !self.data_dir.join(e.filename).exists())
224 .map(|e| e.filename.to_string())
225 .collect();
226 if !missing.is_empty() {
227 return Err(DataError::MissingFiles(missing));
228 }
229 Ok(self.paths())
230 }
231
232 pub fn ensure_ready(&self) -> Result<AssistDataPaths, DataError> {
250 fs::create_dir_all(&self.data_dir).map_err(DataError::Io)?;
251
252 for entry in DEFAULT_KERNELS {
253 let path = self.data_dir.join(entry.filename);
254 let meta_path = self.data_dir.join(format!("{}.meta.json", entry.filename));
255
256 if !path.exists() {
257 eprintln!("Downloading {}...", entry.filename);
258 download(entry, &path, &meta_path)?;
259 continue;
260 }
261
262 let Ok(meta) = read_meta(&meta_path) else {
263 continue;
266 };
267
268 if !local_md5_matches(&path, &meta.md5)? {
274 eprintln!("Re-downloading {} (local MD5 mismatch)...", entry.filename);
275 download(entry, &path, &meta_path)?;
276 continue;
277 }
278
279 if entry.is_static {
280 continue;
281 }
282
283 if is_stale(entry.url, &meta)? {
287 eprintln!("Updating {} (remote changed)...", entry.filename);
288 download(entry, &path, &meta_path)?;
289 }
290 }
291
292 Ok(self.paths())
293 }
294
295 pub fn clean(&self) -> Result<(), DataError> {
297 if self.data_dir.exists() {
298 fs::remove_dir_all(&self.data_dir).map_err(DataError::Io)?;
299 }
300 Ok(())
301 }
302}
303
304fn is_stale(url: &str, meta: &FileMeta) -> Result<bool, DataError> {
307 let response = ureq::head(url)
308 .call()
309 .map_err(|e| DataError::Http(format!("HEAD {url}: {e}")))?;
310
311 let remote_length: Option<u64> = response
312 .headers()
313 .get("content-length")
314 .and_then(|v| v.to_str().ok())
315 .and_then(|v| v.parse().ok());
316
317 let remote_modified: Option<&str> = response
318 .headers()
319 .get("last-modified")
320 .and_then(|v| v.to_str().ok());
321
322 if let (Some(remote), Some(local)) = (remote_length, meta.content_length) {
323 if remote != local {
324 return Ok(true);
325 }
326 }
327
328 if let (Some(remote), Some(local)) = (remote_modified, meta.last_modified.as_deref()) {
329 if remote != local {
330 return Ok(true);
331 }
332 }
333
334 Ok(false)
335}
336
337fn download(entry: &KernelEntry, path: &Path, meta_path: &Path) -> Result<(), DataError> {
338 let response = ureq::get(entry.url)
339 .call()
340 .map_err(|e| DataError::Http(format!("GET {}: {e}", entry.url)))?;
341
342 let content_length: Option<u64> = response
343 .headers()
344 .get("content-length")
345 .and_then(|v| v.to_str().ok())
346 .and_then(|v| v.parse().ok());
347
348 let last_modified: Option<String> = response
349 .headers()
350 .get("last-modified")
351 .and_then(|v| v.to_str().ok())
352 .map(|v| v.to_string());
353
354 if let Some(size) = content_length {
355 eprintln!(" {} ({:.1} MB)", entry.filename, size as f64 / 1_048_576.0);
356 }
357
358 let tmp_path = path.with_extension("tmp");
359 {
360 let mut body = response.into_body();
361 let file = File::create(&tmp_path).map_err(DataError::Io)?;
362 let mut writer = BufWriter::new(file);
363 if entry.gzipped {
364 let mut decoder = flate2::read::GzDecoder::new(body.as_reader());
365 io::copy(&mut decoder, &mut writer).map_err(DataError::Io)?;
366 } else {
367 io::copy(&mut body.as_reader(), &mut writer).map_err(DataError::Io)?;
368 }
369 writer.flush().map_err(DataError::Io)?;
370 }
371
372 let md5_hex = compute_md5(&tmp_path)?;
373
374 fs::rename(&tmp_path, path).map_err(DataError::Io)?;
375
376 let now = std::time::SystemTime::now()
377 .duration_since(std::time::UNIX_EPOCH)
378 .unwrap()
379 .as_secs();
380 let meta = FileMeta {
381 url: entry.url.to_string(),
382 downloaded_at: now,
383 content_length,
384 last_modified,
385 md5: md5_hex,
386 };
387 let json =
388 serde_json::to_string_pretty(&meta).map_err(|e| DataError::Io(io::Error::other(e)))?;
389 fs::write(meta_path, json).map_err(DataError::Io)?;
390
391 Ok(())
392}
393
394fn read_meta(path: &Path) -> Result<FileMeta, DataError> {
395 let content = fs::read_to_string(path).map_err(DataError::Io)?;
396 serde_json::from_str(&content)
397 .map_err(|e| DataError::Io(io::Error::new(io::ErrorKind::InvalidData, e)))
398}
399
400fn local_md5_matches(path: &Path, expected_hex: &str) -> Result<bool, DataError> {
404 if expected_hex.is_empty() {
405 return Ok(true);
406 }
407 let actual = compute_md5(path)?;
408 Ok(actual.eq_ignore_ascii_case(expected_hex))
409}
410
411fn compute_md5(path: &Path) -> Result<String, DataError> {
412 let mut file = File::open(path).map_err(DataError::Io)?;
413 let mut context = md5::Context::new();
414 let mut buffer = [0u8; 65536];
415 loop {
416 let n = file.read(&mut buffer).map_err(DataError::Io)?;
417 if n == 0 {
418 break;
419 }
420 context.consume(&buffer[..n]);
421 }
422 Ok(format!("{:x}", context.compute()))
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
432 fn compute_md5_matches_rfc1321_vectors() {
433 let dir = tempfile::tempdir().unwrap();
434 let cases: &[(&[u8], &str)] = &[
435 (b"", "d41d8cd98f00b204e9800998ecf8427e"),
436 (b"abc", "900150983cd24fb0d6963f7d28e17f72"),
437 (
438 b"The quick brown fox jumps over the lazy dog",
439 "9e107d9d372bb6826bd81d3542a419d6",
440 ),
441 ];
442 for (i, (payload, expected)) in cases.iter().enumerate() {
443 let path = dir.path().join(format!("case_{i}.bin"));
444 fs::write(&path, payload).unwrap();
445 let got = compute_md5(&path).unwrap();
446 assert_eq!(
447 got,
448 *expected,
449 "case {i}: {:?}",
450 std::str::from_utf8(payload)
451 );
452 }
453 }
454
455 #[test]
456 fn local_md5_matches_detects_correct_and_incorrect_hashes() {
457 let dir = tempfile::tempdir().unwrap();
458 let path = dir.path().join("payload.txt");
459 fs::write(&path, b"hello").unwrap();
460 let actual = compute_md5(&path).unwrap();
461
462 assert!(local_md5_matches(&path, &actual).unwrap());
464 assert!(local_md5_matches(&path, &actual.to_uppercase()).unwrap());
466 assert!(!local_md5_matches(&path, "0".repeat(32).as_str()).unwrap());
468 }
469
470 #[test]
471 fn local_md5_matches_skips_check_when_sidecar_has_empty_hash() {
472 let dir = tempfile::tempdir().unwrap();
476 let nonexistent = dir.path().join("not_there.bin");
477 assert!(local_md5_matches(&nonexistent, "").unwrap());
478 }
479
480 #[test]
487 fn local_md5_matches_propagates_io_error_on_missing_file() {
488 let dir = tempfile::tempdir().unwrap();
489 let missing = dir.path().join("absent.bin");
490 let err = local_md5_matches(&missing, "deadbeef").unwrap_err();
491 assert!(
492 matches!(err, DataError::Io(_)),
493 "expected DataError::Io, got {err:?}"
494 );
495 }
496
497 #[test]
502 fn read_meta_errors_on_missing_file() {
503 let dir = tempfile::tempdir().unwrap();
504 let missing = dir.path().join("kernel.meta.json");
505 let err = read_meta(&missing).unwrap_err();
506 assert!(matches!(err, DataError::Io(_)));
507 }
508
509 #[test]
515 fn is_stale_propagates_http_error_on_unreachable_host() {
516 let url = "http://nx.invalid/never-resolves";
517 let meta = FileMeta {
518 url: url.into(),
519 downloaded_at: 0,
520 content_length: Some(1),
521 last_modified: None,
522 md5: String::new(),
523 };
524 let err = is_stale(url, &meta).unwrap_err();
525 assert!(
526 matches!(err, DataError::Http(_)),
527 "expected DataError::Http, got {err:?}"
528 );
529 }
530
531 #[test]
535 fn every_default_kernel_has_a_path_field() {
536 let dm = DataManager::with_dir("/tmp/check");
537 let paths = dm.paths();
538 let all_paths = [
539 &paths.planets,
540 &paths.asteroids,
541 &paths.obscodes,
542 &paths.eop_high_prec,
543 &paths.eop_historical,
544 &paths.eop_predict,
545 ];
546 for entry in DEFAULT_KERNELS {
547 let expected = dm.data_dir.join(entry.filename);
548 assert!(
549 all_paths.iter().any(|p| **p == expected),
550 "kernel {:?} in DEFAULT_KERNELS has no corresponding field in AssistDataPaths",
551 entry.filename
552 );
553 }
554 for p in all_paths {
557 let filename = p.file_name().unwrap().to_str().unwrap();
558 assert!(
559 DEFAULT_KERNELS.iter().any(|e| e.filename == filename),
560 "AssistDataPaths field points at {filename:?}, which is not in DEFAULT_KERNELS"
561 );
562 }
563 }
564
565 #[test]
566 fn eop_kernels_returns_spice_idiomatic_load_order() {
567 let dm = DataManager::with_dir("/tmp/check");
570 let paths = dm.paths();
571 let kernels = paths.eop_kernels();
572 assert_eq!(kernels[0], &paths.eop_predict);
573 assert_eq!(kernels[1], &paths.eop_historical);
574 assert_eq!(kernels[2], &paths.eop_high_prec);
575 }
576
577 #[test]
578 fn meta_round_trips_through_sidecar() {
579 let dir = tempfile::tempdir().unwrap();
582 let meta_path = dir.path().join("kernel.meta.json");
583 let meta = FileMeta {
584 url: "https://example.com/kernel.bsp".into(),
585 downloaded_at: 1_700_000_000,
586 content_length: Some(42),
587 last_modified: Some("Mon, 21 Oct 2024 12:00:00 GMT".into()),
588 md5: "d41d8cd98f00b204e9800998ecf8427e".into(),
589 };
590 let json = serde_json::to_string_pretty(&meta).unwrap();
591 fs::write(&meta_path, json).unwrap();
592
593 let back = read_meta(&meta_path).unwrap();
594 assert_eq!(back.url, meta.url);
595 assert_eq!(back.downloaded_at, meta.downloaded_at);
596 assert_eq!(back.content_length, meta.content_length);
597 assert_eq!(back.last_modified, meta.last_modified);
598 assert_eq!(back.md5, meta.md5);
599 }
600}