use std::env;
use std::fs::{self, File};
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::Result;
use crate::StarfieldError;
use indicatif::{ProgressBar, ProgressStyle};
const HIPPARCOS_URL: &str = "https://cdsarc.cds.unistra.fr/ftp/cats/I/239/hip_main.dat";
const JPL_BSP_URL: &str = "https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/";
const NAIF_SATELLITES_URL: &str =
"https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/satellites/";
pub fn get_cache_dir() -> PathBuf {
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".cache").join("starfield")
}
pub fn ensure_cache_dir() -> io::Result<PathBuf> {
let cache_dir = get_cache_dir();
fs::create_dir_all(&cache_dir)?;
Ok(cache_dir)
}
pub(crate) fn file_exists_and_not_empty<P: AsRef<Path>>(path: P) -> bool {
match fs::metadata(path) {
Ok(metadata) => metadata.is_file() && metadata.len() > 0,
Err(_) => false,
}
}
fn download_file<P: AsRef<Path>>(url: &str, path: P) -> Result<()> {
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent).map_err(StarfieldError::IoError)?;
}
let temp_path = path.as_ref().with_extension("tmp");
let mut file = BufWriter::new(File::create(&temp_path).map_err(StarfieldError::IoError)?);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| StarfieldError::DataError(format!("Failed to create HTTP client: {}", e)))?;
let mut response = client
.get(url)
.send()
.map_err(|e| StarfieldError::DataError(format!("Failed to download file: {}", e)))?;
if !response.status().is_success() {
return Err(StarfieldError::DataError(format!(
"Failed to download file, status: {}",
response.status()
)));
}
let mut buffer = [0; 8192];
loop {
let bytes_read = response
.read(&mut buffer)
.map_err(|e| StarfieldError::DataError(format!("Failed to read response: {}", e)))?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])
.map_err(StarfieldError::IoError)?;
}
file.flush().map_err(StarfieldError::IoError)?;
drop(file);
fs::rename(temp_path, path).map_err(StarfieldError::IoError)?;
Ok(())
}
#[allow(dead_code)]
fn decompress_gzip<P: AsRef<Path>, Q: AsRef<Path>>(gz_path: P, output_path: Q) -> Result<()> {
let file = File::open(&gz_path).map_err(StarfieldError::IoError)?;
let mut header = [0u8; 2];
{
let mut file_clone = file.try_clone().map_err(StarfieldError::IoError)?;
if file_clone.read_exact(&mut header).is_err() || header != [0x1F, 0x8B] {
return Err(StarfieldError::DataError(format!(
"Invalid gzip file: {:?} is not a valid gzip header",
header
)));
}
}
let gz = BufReader::new(file);
let mut decoder = flate2::read::GzDecoder::new(gz);
let mut test_buffer = [0u8; 1024];
if decoder.read(&mut test_buffer).is_err() {
let _ = fs::remove_file(&gz_path);
return Err(StarfieldError::DataError(
"Downloaded file appears to be corrupt. File removed, please try again.".to_string(),
));
}
let file = File::open(gz_path).map_err(StarfieldError::IoError)?;
let gz = BufReader::new(file);
let mut decoder = flate2::read::GzDecoder::new(gz);
let output_file = File::create(&output_path).map_err(StarfieldError::IoError)?;
let mut writer = BufWriter::new(output_file);
match io::copy(&mut decoder, &mut writer) {
Ok(_) => {
writer.flush().map_err(StarfieldError::IoError)?;
Ok(())
}
Err(e) => {
let _ = fs::remove_file(&output_path);
Err(StarfieldError::DataError(format!(
"Failed to decompress file: {}",
e
)))
}
}
}
pub fn resolve_url(filename: &str) -> Option<String> {
if filename.contains("://") {
return Some(filename.to_string());
}
if filename.ends_with(".bsp") {
let base = if filename.starts_with("jup") {
NAIF_SATELLITES_URL
} else {
JPL_BSP_URL
};
return Some(format!("{}{}", base, filename));
}
None
}
pub fn download_file_with_progress<P: AsRef<Path>>(url: &str, path: P) -> Result<()> {
if let Some(parent) = path.as_ref().parent() {
fs::create_dir_all(parent).map_err(StarfieldError::IoError)?;
}
let temp_path = path.as_ref().with_extension("tmp");
let mut file = BufWriter::new(File::create(&temp_path).map_err(StarfieldError::IoError)?);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(600))
.build()
.map_err(|e| StarfieldError::DataError(format!("Failed to create HTTP client: {}", e)))?;
let response = client
.get(url)
.send()
.map_err(|e| StarfieldError::DataError(format!("Failed to download {}: {}", url, e)))?;
if !response.status().is_success() {
return Err(StarfieldError::DataError(format!(
"Download failed for {}: HTTP {}",
url,
response.status()
)));
}
let total_size = response.content_length().unwrap_or(0);
let pb = if total_size > 0 {
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("[{bar:40}] {percent}% {bytes}/{total_bytes} ({bytes_per_sec})")
.unwrap()
.progress_chars("##-"),
);
Some(pb)
} else {
None
};
let mut reader = io::BufReader::new(response);
let mut downloaded: u64 = 0;
let mut buffer = [0u8; 131_072];
loop {
let bytes_read = reader
.read(&mut buffer)
.map_err(|e| StarfieldError::DataError(format!("Failed to read response: {}", e)))?;
if bytes_read == 0 {
break;
}
file.write_all(&buffer[..bytes_read])
.map_err(StarfieldError::IoError)?;
downloaded += bytes_read as u64;
if let Some(ref pb) = pb {
pb.set_position(downloaded);
}
}
if let Some(ref pb) = pb {
pb.finish_and_clear();
}
file.flush().map_err(StarfieldError::IoError)?;
drop(file);
fs::rename(temp_path, path).map_err(StarfieldError::IoError)?;
Ok(())
}
pub fn download_or_cache(filename: &str, data_dir: Option<&Path>) -> Result<PathBuf> {
let dir = match data_dir {
Some(d) => {
fs::create_dir_all(d).map_err(StarfieldError::IoError)?;
d.to_path_buf()
}
None => ensure_cache_dir().map_err(StarfieldError::IoError)?,
};
let local_path = dir.join(filename);
if file_exists_and_not_empty(&local_path) {
return Ok(local_path);
}
let url = resolve_url(filename).ok_or_else(|| {
StarfieldError::DataError(format!(
"Unknown file '{}'. Provide a recognized filename (e.g. de421.bsp) or a full URL.",
filename
))
})?;
eprintln!("Downloading {} ...", url);
download_file_with_progress(&url, &local_path)?;
eprintln!("Saved to {}", local_path.display());
Ok(local_path)
}
pub fn download_hipparcos() -> Result<PathBuf> {
let cache_dir = ensure_cache_dir().map_err(StarfieldError::IoError)?;
let dat_path = cache_dir.join("hip_main.dat");
if file_exists_and_not_empty(&dat_path) {
println!("Using cached Hipparcos catalog from {}", dat_path.display());
return Ok(dat_path);
}
let project_root_dat = PathBuf::from("hip_main.dat");
if file_exists_and_not_empty(&project_root_dat) {
println!(
"Using Hipparcos catalog from project root: {}",
project_root_dat.display()
);
fs::copy(&project_root_dat, &dat_path).map_err(StarfieldError::IoError)?;
println!("Copied Hipparcos catalog to cache: {}", dat_path.display());
return Ok(dat_path);
}
println!("Downloading Hipparcos catalog from {}...", HIPPARCOS_URL);
println!("This may take a moment as the catalog is approximately 36MB");
match download_file(HIPPARCOS_URL, &dat_path) {
Ok(_) => {
println!(
"Hipparcos catalog downloaded successfully to {}",
dat_path.display()
);
Ok(dat_path)
}
Err(e) => {
println!("Failed to download Hipparcos catalog: {}", e);
println!("Check your internet connection or try again later.");
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_dir() {
let cache_dir = get_cache_dir();
assert!(cache_dir.to_str().unwrap().contains(".cache/starfield"));
}
#[test]
fn test_resolve_url_bsp() {
assert_eq!(
resolve_url("de421.bsp"),
Some("https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp".to_string())
);
}
#[test]
fn test_resolve_url_jupiter_bsp() {
assert_eq!(
resolve_url("jup365.bsp"),
Some(
"https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/satellites/jup365.bsp"
.to_string()
)
);
}
#[test]
fn test_resolve_url_full_url_passthrough() {
let url = "https://example.com/custom.bsp";
assert_eq!(resolve_url(url), Some(url.to_string()));
}
#[test]
fn test_resolve_url_unknown() {
assert_eq!(resolve_url("unknown.xyz"), None);
}
#[test]
fn test_download_or_cache_cached_file() {
let dir = tempfile::tempdir().unwrap();
let test_file = dir.path().join("test.bsp");
std::fs::write(&test_file, b"fake bsp data").unwrap();
let result = download_or_cache("test.bsp", Some(dir.path()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_file);
}
#[test]
fn test_download_or_cache_unknown_file() {
let dir = tempfile::tempdir().unwrap();
let result = download_or_cache("unknown.xyz", Some(dir.path()));
assert!(result.is_err());
}
#[test]
fn test_known_endpoints_reachable() {
let filenames = ["de421.bsp", "de405.bsp", "de430t.bsp", "jup365.bsp"];
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("Failed to build HTTP client");
for filename in filenames {
let url = resolve_url(filename).expect("resolve_url returned None");
let response = client
.head(&url)
.send()
.unwrap_or_else(|e| panic!("HEAD request failed for {}: {}", url, e));
assert!(
response.status().is_success(),
"Endpoint {} returned HTTP {}",
url,
response.status()
);
}
}
}