use crate::datasets::icgem::body::ICGEMBody;
use crate::utils::BraheError;
use crate::utils::cache::get_icgem_cache_dir;
use crate::utils::fs::atomic_write;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IndexEntry {
pub body: ICGEMBody,
pub name: String,
pub year: Option<u16>,
pub degree: u32,
pub download_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct IndexFile {
pub fetched_at: u64,
pub entries: Vec<IndexEntry>,
}
pub(crate) const DEFAULT_INDEX_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
pub(crate) const EARTH_INDEX_FILE: &str = "index_earth.json";
pub(crate) const CELESTIAL_INDEX_FILE: &str = "index_celestial.json";
pub(crate) fn index_path_for(body: &ICGEMBody) -> Result<PathBuf, BraheError> {
let dir = get_icgem_cache_dir()?;
let filename = if body.is_earth() {
EARTH_INDEX_FILE
} else {
CELESTIAL_INDEX_FILE
};
Ok(PathBuf::from(dir).join(filename))
}
pub(crate) fn now_unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn read_index_file(path: &Path) -> Result<Option<IndexFile>, BraheError> {
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(path).map_err(|e| {
BraheError::Error(format!(
"Failed to read ICGEM index file {}: {}",
path.display(),
e
))
})?;
let parsed: IndexFile = serde_json::from_str(&data).map_err(|e| {
BraheError::Error(format!(
"Failed to parse ICGEM index file {}: {}",
path.display(),
e
))
})?;
Ok(Some(parsed))
}
pub(crate) fn write_index_file(path: &Path, file: &IndexFile) -> Result<(), BraheError> {
let data = serde_json::to_string_pretty(file).map_err(|e| {
BraheError::Error(format!("Failed to serialize ICGEM index: {}", e))
})?;
atomic_write(path, data.as_bytes()).map_err(|e| {
BraheError::Error(format!(
"Failed to write ICGEM index to {}: {}",
path.display(),
e
))
})
}
pub(crate) const ICGEM_BASE_URL: &str = "https://icgem.gfz.de";
pub(crate) const EARTH_PATH: &str = "/tom_longtime";
pub(crate) const CELESTIAL_PATH: &str = "/tom_celestial";
pub(crate) fn fetch_index_with_url(
body: &ICGEMBody,
base_url: &str,
) -> Result<IndexFile, BraheError> {
use std::io::Read;
let path = if body.is_earth() { EARTH_PATH } else { CELESTIAL_PATH };
let url = format!("{}{}", base_url, path);
let response = ureq::get(&url).call().map_err(|e| {
BraheError::Error(format!("Failed to fetch ICGEM index from {}: {}", url, e))
})?;
let mut buf = String::new();
response
.into_body()
.into_reader()
.read_to_string(&mut buf)
.map_err(|e| {
BraheError::Error(format!("Failed to read ICGEM index from {}: {}", url, e))
})?;
let entries = if body.is_earth() {
crate::datasets::icgem::parser::parse_earth_catalog(&buf)?
} else {
crate::datasets::icgem::parser::parse_celestial_catalog(&buf)?
};
Ok(IndexFile {
fetched_at: now_unix_seconds(),
entries,
})
}
pub fn list_icgem_models(body: ICGEMBody) -> Result<Vec<IndexEntry>, BraheError> {
list_icgem_models_with_url(&body, ICGEM_BASE_URL)
}
pub fn list_icgem_models_with_url(
body: &ICGEMBody,
base_url: &str,
) -> Result<Vec<IndexEntry>, BraheError> {
let path = index_path_for(body)?;
let existing = read_index_file(&path)?;
let now = now_unix_seconds();
let needs_refresh = match &existing {
None => true,
Some(f) => now.saturating_sub(f.fetched_at) > DEFAULT_INDEX_TTL_SECONDS,
};
let filter_for_body = |all: Vec<IndexEntry>| -> Vec<IndexEntry> {
all.into_iter().filter(|e| &e.body == body).collect()
};
if !needs_refresh {
return Ok(filter_for_body(existing.unwrap().entries));
}
match fetch_index_with_url(body, base_url) {
Ok(fresh) => {
write_index_file(&path, &fresh)?;
Ok(filter_for_body(fresh.entries))
}
Err(fetch_err) => {
if let Some(stale) = existing {
eprintln!(
"Warning: ICGEM index refresh failed ({}); using stale cache from {}",
fetch_err, path.display()
);
Ok(filter_for_body(stale.entries))
} else {
Err(fetch_err)
}
}
}
}
pub fn refresh_icgem_index(body: ICGEMBody) -> Result<(), BraheError> {
refresh_icgem_index_with_url(&body, ICGEM_BASE_URL)
}
pub(crate) fn refresh_icgem_index_with_url(
body: &ICGEMBody,
base_url: &str,
) -> Result<(), BraheError> {
let fresh = fetch_index_with_url(body, base_url)?;
let path = index_path_for(body)?;
write_index_file(&path, &fresh)
}
pub fn refresh_all_icgem_indexes() -> Result<(), BraheError> {
refresh_icgem_index(ICGEMBody::Earth)?;
refresh_icgem_index(ICGEMBody::Moon)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn test_index_entry_round_trip_json() {
let entry = IndexEntry {
body: ICGEMBody::Earth,
name: "EGM2008".into(),
year: Some(2008),
degree: 2190,
download_path: "/getmodel/gfc/abc/EGM2008.gfc".into(),
};
let s = serde_json::to_string(&entry).unwrap();
let back: IndexEntry = serde_json::from_str(&s).unwrap();
assert_eq!(entry, back);
}
#[test]
fn test_index_file_round_trip_json() {
let file = IndexFile {
fetched_at: 1_700_000_000,
entries: vec![IndexEntry {
body: ICGEMBody::Moon,
name: "GRGM1200B".into(),
year: Some(2016),
degree: 1200,
download_path: "/getmodel/gfc/xyz/GRGM1200B.gfc".into(),
}],
};
let s = serde_json::to_string(&file).unwrap();
let back: IndexFile = serde_json::from_str(&s).unwrap();
assert_eq!(back.entries.len(), 1);
assert_eq!(back.fetched_at, 1_700_000_000);
}
#[test]
fn test_index_path_dispatches_by_body() {
let earth = index_path_for(&ICGEMBody::Earth).unwrap();
assert!(earth.to_string_lossy().ends_with("index_earth.json"));
let moon = index_path_for(&ICGEMBody::Moon).unwrap();
assert!(moon.to_string_lossy().ends_with("index_celestial.json"));
let pluto = index_path_for(&ICGEMBody::Other("pluto".into())).unwrap();
assert!(pluto.to_string_lossy().ends_with("index_celestial.json"));
}
#[test]
fn test_read_index_file_missing_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.json");
assert!(read_index_file(&path).unwrap().is_none());
}
#[test]
fn test_write_then_read_index_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("x.json");
let file = IndexFile {
fetched_at: 42,
entries: vec![],
};
write_index_file(&path, &file).unwrap();
let back = read_index_file(&path).unwrap().unwrap();
assert_eq!(back.fetched_at, 42);
}
#[test]
fn test_fetch_index_http_404() {
use httpmock::prelude::*;
let server = MockServer::start();
let _m = server.mock(|when, then| {
when.method(GET).path("/tom_longtime");
then.status(404);
});
let result = fetch_index_with_url(&ICGEMBody::Earth, &server.base_url());
assert!(result.is_err());
}
#[test]
fn test_fetch_index_success_serves_fixture() {
use httpmock::prelude::*;
let fixture =
std::fs::read_to_string("test_assets/icgem/tom_longtime_sample.html").unwrap();
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path_includes("/tom_longtime");
then.status(200).body(&fixture);
});
let result = fetch_index_with_url(&ICGEMBody::Earth, &server.base_url());
let file = result.unwrap();
assert!(!file.entries.is_empty());
mock.assert_calls(1);
}
#[test]
#[serial_test::serial]
fn test_list_models_uses_fresh_cache() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("BRAHE_CACHE", dir.path()); }
let path = index_path_for(&ICGEMBody::Earth).unwrap();
let file = IndexFile {
fetched_at: now_unix_seconds(),
entries: vec![IndexEntry {
body: ICGEMBody::Earth,
name: "JGM3".into(),
year: Some(1996),
degree: 70,
download_path: "/getmodel/gfc/abc/JGM3.gfc".into(),
}],
};
write_index_file(&path, &file).unwrap();
let entries = list_icgem_models_with_url(&ICGEMBody::Earth, "http://127.0.0.1:1").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "JGM3");
unsafe { std::env::remove_var("BRAHE_CACHE"); }
}
#[test]
#[serial_test::serial]
fn test_list_models_refreshes_stale_cache() {
use httpmock::prelude::*;
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("BRAHE_CACHE", dir.path()); }
let path = index_path_for(&ICGEMBody::Earth).unwrap();
let stale = IndexFile {
fetched_at: 0,
entries: vec![],
};
write_index_file(&path, &stale).unwrap();
let fixture =
std::fs::read_to_string("test_assets/icgem/tom_longtime_sample.html").unwrap();
let server = MockServer::start();
let _m = server.mock(|when, then| {
when.method(GET).path_includes("/tom_longtime");
then.status(200).body(&fixture);
});
let entries = list_icgem_models_with_url(&ICGEMBody::Earth, &server.base_url()).unwrap();
assert!(!entries.is_empty());
unsafe { std::env::remove_var("BRAHE_CACHE"); }
}
#[test]
#[serial_test::serial]
fn test_list_models_stale_fallback_on_network_failure() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("BRAHE_CACHE", dir.path()); }
let path = index_path_for(&ICGEMBody::Earth).unwrap();
let stale = IndexFile {
fetched_at: 0,
entries: vec![IndexEntry {
body: ICGEMBody::Earth,
name: "STALE".into(),
year: None,
degree: 10,
download_path: "/getmodel/gfc/x/STALE.gfc".into(),
}],
};
write_index_file(&path, &stale).unwrap();
let entries = list_icgem_models_with_url(&ICGEMBody::Earth, "http://127.0.0.1:1").unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "STALE");
unsafe { std::env::remove_var("BRAHE_CACHE"); }
}
#[test]
#[serial_test::serial]
fn test_list_models_filters_celestial_by_body() {
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("BRAHE_CACHE", dir.path()); }
let path = index_path_for(&ICGEMBody::Moon).unwrap(); let file = IndexFile {
fetched_at: now_unix_seconds(),
entries: vec![
IndexEntry {
body: ICGEMBody::Moon,
name: "GRGM1200B".into(),
year: Some(2016),
degree: 1200,
download_path: "/getmodel/gfc/m/GRGM1200B.gfc".into(),
},
IndexEntry {
body: ICGEMBody::Mars,
name: "MRO120F".into(),
year: Some(2016),
degree: 120,
download_path: "/getmodel/gfc/r/MRO120F.gfc".into(),
},
IndexEntry {
body: ICGEMBody::Venus,
name: "MGNP180U".into(),
year: Some(1999),
degree: 180,
download_path: "/getmodel/gfc/v/MGNP180U.gfc".into(),
},
],
};
write_index_file(&path, &file).unwrap();
let moon_entries =
list_icgem_models_with_url(&ICGEMBody::Moon, "http://127.0.0.1:1").unwrap();
assert_eq!(moon_entries.len(), 1);
assert_eq!(moon_entries[0].body, ICGEMBody::Moon);
assert_eq!(moon_entries[0].name, "GRGM1200B");
let mars_entries =
list_icgem_models_with_url(&ICGEMBody::Mars, "http://127.0.0.1:1").unwrap();
assert_eq!(mars_entries.len(), 1);
assert_eq!(mars_entries[0].body, ICGEMBody::Mars);
assert_eq!(mars_entries[0].name, "MRO120F");
let venus_entries =
list_icgem_models_with_url(&ICGEMBody::Venus, "http://127.0.0.1:1").unwrap();
assert_eq!(venus_entries.len(), 1);
assert_eq!(venus_entries[0].body, ICGEMBody::Venus);
let ceres_entries =
list_icgem_models_with_url(&ICGEMBody::Ceres, "http://127.0.0.1:1").unwrap();
assert!(ceres_entries.is_empty());
unsafe { std::env::remove_var("BRAHE_CACHE"); }
}
}