use crate::error::{DOWNLOAD_FAILED_PREFIX, Error, Result, dedupe_io_prefix};
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use std::ptr::NonNull;
pub struct Context {
raw: NonNull<empyrean_sys::EmpyreanContext>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DataTier {
Approximate,
Basic,
#[default]
Standard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DataDirOptions {
pub refresh: bool,
pub tier: DataTier,
}
impl Default for DataDirOptions {
fn default() -> Self {
Self {
refresh: true,
tier: DataTier::Standard,
}
}
}
const OFFLINE_ENV: &str = "EMPYREAN_OFFLINE";
fn offline_env_is_set() -> bool {
std::env::var(OFFLINE_ENV)
.map(|v| v == "1")
.unwrap_or(false)
}
unsafe impl Send for Context {}
unsafe impl Sync for Context {}
impl Context {
pub fn new_minimal(de440_path: impl AsRef<Path>, gm_path: impl AsRef<Path>) -> Result<Self> {
let de440_c = path_to_cstring(de440_path.as_ref())?;
let gm_c = path_to_cstring(gm_path.as_ref())?;
let raw =
unsafe { empyrean_sys::empyrean_context_new_minimal(de440_c.as_ptr(), gm_c.as_ptr()) };
NonNull::new(raw).map(|raw| Context { raw }).ok_or_else(|| {
let mut err = Error::from_null_ptr();
err.message = dedupe_io_prefix(&err.message);
err
})
}
pub fn from_data_dir(data_dir: Option<&Path>) -> Result<Self> {
if !apply_offline_floor(true) {
return Self::from_data_dir_with(
data_dir,
DataDirOptions {
refresh: false,
..DataDirOptions::default()
},
);
}
let c_path = match data_dir {
Some(d) => Some(path_to_cstring(d)?),
None => None,
};
let raw_path = c_path
.as_ref()
.map(|c| c.as_ptr())
.unwrap_or(std::ptr::null());
let raw = unsafe { empyrean_sys::empyrean_context_from_data_dir(raw_path) };
NonNull::new(raw)
.map(|raw| Context { raw })
.ok_or_else(|| construction_error(data_dir))
}
pub fn from_data_dir_with(data_dir: Option<&Path>, options: DataDirOptions) -> Result<Self> {
let c_path = match data_dir {
Some(d) => Some(path_to_cstring(d)?),
None => None,
};
let raw_path = c_path
.as_ref()
.map(|c| c.as_ptr())
.unwrap_or(std::ptr::null());
let refresh = apply_offline_floor(options.refresh);
let ffi_options = empyrean_sys::EmpyreanDataDirOptions {
refresh: if refresh {
empyrean_sys::EMPYREAN_DATA_REFRESH_ON
} else {
empyrean_sys::EMPYREAN_DATA_REFRESH_OFF
},
tier: match options.tier {
DataTier::Approximate => empyrean_sys::EMPYREAN_DATA_TIER_APPROXIMATE,
DataTier::Basic => empyrean_sys::EMPYREAN_DATA_TIER_BASIC,
DataTier::Standard => empyrean_sys::EMPYREAN_DATA_TIER_STANDARD,
},
};
let raw =
unsafe { empyrean_sys::empyrean_context_from_data_dir_with(raw_path, &ffi_options) };
NonNull::new(raw).map(|raw| Context { raw }).ok_or_else(|| {
let mut err = Error::from_null_ptr();
err.message = dedupe_io_prefix(&err.message);
err.missing_data_files = drain_missing_data_files();
if !err.missing_data_files.is_empty() {
err.code = -2;
return err;
}
augment_construction_error(err, data_dir)
})
}
pub fn with_spk(&mut self, spk_path: impl AsRef<Path>) -> Result<()> {
let c_path = path_to_cstring(spk_path.as_ref())?;
let code =
unsafe { empyrean_sys::empyrean_context_with_spk(self.raw.as_ptr(), c_path.as_ptr()) };
if code != 0 {
let mut err = Error::capture(code);
err.message = dedupe_io_prefix(&err.message);
return Err(err);
}
Ok(())
}
pub(crate) fn as_raw(&self) -> *const empyrean_sys::EmpyreanContext {
self.raw.as_ptr()
}
}
const CORE_KERNELS: &[&str] = &[
"de440.bsp",
"gm_de440.tpc",
"sb441-n16.bsp",
"obscodes_extended.json",
"earth_latest_high_prec.bpc",
"bias.dat",
];
fn first_missing_core_kernel(dir: &Path) -> Option<&'static str> {
CORE_KERNELS.iter().copied().find(|f| !dir.join(f).exists())
}
struct UnusableDataDir {
symlink_target: Option<PathBuf>,
reason: String,
}
fn inspect_data_dir(dir: &Path) -> std::result::Result<(), UnusableDataDir> {
let link_meta = match std::fs::symlink_metadata(dir) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(source) => {
return Err(UnusableDataDir {
symlink_target: None,
reason: format!("could not be inspected: {source}"),
});
}
};
let symlink_target = if link_meta.file_type().is_symlink() {
std::fs::read_link(dir).ok()
} else {
None
};
match std::fs::metadata(dir) {
Ok(m) if m.is_dir() => Ok(()),
Ok(_) => Err(UnusableDataDir {
symlink_target,
reason: "exists but is not a directory".to_string(),
}),
Err(source) => Err(UnusableDataDir {
symlink_target,
reason: format!("does not resolve to a directory: {source}"),
}),
}
}
fn augment_with_unusable_data_dir(base: &str, dir: &Path, bad: &UnusableDataDir) -> String {
let what = match &bad.symlink_target {
Some(target) => format!(
"the empyrean data directory '{}' is a symbolic link to '{}' that {}",
dir.display(),
target.display(),
bad.reason,
),
None => format!(
"the empyrean data directory '{}' {}",
dir.display(),
bad.reason,
),
};
format!(
"{base} — {what}. Repoint or remove that path, or set EMPYREAN_DATA_DIR to a \
directory that already contains the kernels."
)
}
fn augment_with_data_dir(base: &str, dir: &Path, missing: Option<&str>) -> String {
if base.starts_with(DOWNLOAD_FAILED_PREFIX) {
return format!("{base} (data directory: '{}')", dir.display());
}
match missing {
Some(file) => format!(
"{base} — empyrean data directory '{}' may be incompletely provisioned \
(required kernel '{file}' is absent). Run `empyrean::download_data(None)` to \
provision it, or set EMPYREAN_DATA_DIR to a directory that already contains \
the kernels.",
dir.display(),
),
None => format!("{base} (data directory: '{}')", dir.display()),
}
}
fn construction_error(data_dir: Option<&Path>) -> Error {
let mut err = Error::from_null_ptr();
err.message = dedupe_io_prefix(&err.message);
err.missing_data_files = drain_missing_data_files();
if !err.missing_data_files.is_empty() {
err.code = -2;
return err;
}
augment_construction_error(err, data_dir)
}
fn augment_construction_error(mut err: Error, data_dir: Option<&Path>) -> Error {
if err
.message
.starts_with(crate::error::DOWNLOAD_FAILED_PREFIX)
{
err.code = -2;
}
let resolved = data_dir
.map(Path::to_path_buf)
.or_else(|| default_data_dir().ok());
if let Some(dir) = resolved {
if let Err(bad) = inspect_data_dir(&dir) {
err.message = augment_with_unusable_data_dir(&err.message, &dir, &bad);
return err;
}
let missing = first_missing_core_kernel(&dir);
if missing.is_some() {
err.code = -2;
}
err.message = augment_with_data_dir(&err.message, &dir, missing);
}
err
}
pub fn offline_floor_is_active() -> bool {
offline_env_is_set()
}
fn apply_offline_floor(requested_refresh: bool) -> bool {
if requested_refresh && offline_env_is_set() {
eprintln!(
"empyrean: {OFFLINE_ENV}=1 — building the context in strict-offline mode \
(no downloads). Kernels must already be present in the data directory; \
the constructor will fail naming any that are not."
);
return false;
}
requested_refresh
}
fn refuse_download_under_offline_floor() -> Result<()> {
if offline_env_is_set() {
return Err(Error::invalid_input(format!(
"{OFFLINE_ENV}=1 is set — refusing to provision kernels, because downloading \
them is the entire operation and there is no offline form of it. Build the \
context against an already-provisioned directory with `refresh: false` \
(Python `refresh=False`, CLI `--no-refresh`), or unset {OFFLINE_ENV} for the \
process that must provision."
)));
}
Ok(())
}
fn drain_missing_data_files() -> Vec<String> {
let mut out = empyrean_sys::EmpyreanMissingDataFiles {
files: std::ptr::null_mut(),
num_files: 0,
};
let code = unsafe { empyrean_sys::empyrean_missing_data_files(&mut out) };
if code != 0 || out.files.is_null() || out.num_files == 0 {
return Vec::new();
}
let files = unsafe {
std::slice::from_raw_parts(out.files, out.num_files)
.iter()
.map(|&p| {
if p.is_null() {
String::new()
} else {
CStr::from_ptr(p).to_string_lossy().into_owned()
}
})
.collect()
};
unsafe { empyrean_sys::empyrean_missing_data_files_free(&mut out) };
files
}
pub fn default_data_dir() -> Result<std::path::PathBuf> {
let raw = unsafe { empyrean_sys::empyrean_default_data_dir() };
if raw.is_null() {
return Err(Error::capture(-1));
}
let path = unsafe { CStr::from_ptr(raw) }
.to_str()
.map(std::path::PathBuf::from)
.map_err(|_| Error::invalid_input("default data dir is not valid UTF-8"));
unsafe { empyrean_sys::empyrean_string_free(raw) };
path
}
pub fn download_data(data_dir: Option<&Path>) -> Result<PathBuf> {
refuse_download_under_offline_floor()?;
let c_path = match data_dir {
Some(d) => Some(path_to_cstring(d)?),
None => None,
};
let raw_path = c_path
.as_ref()
.map(|c| c.as_ptr())
.unwrap_or(std::ptr::null());
let code = unsafe { empyrean_sys::empyrean_download_data(raw_path) };
if code != 0 {
let mut err = Error::capture(code);
err.message = dedupe_io_prefix(&err.message);
err.missing_data_files = drain_missing_data_files();
return Err(augment_construction_error(err, data_dir));
}
match data_dir {
Some(d) => Ok(d.to_path_buf()),
None => default_data_dir(),
}
}
impl Drop for Context {
fn drop(&mut self) {
unsafe { empyrean_sys::empyrean_context_free(self.raw.as_ptr()) }
}
}
fn path_to_cstring(path: &Path) -> Result<CString> {
let bytes = path
.to_str()
.ok_or_else(|| Error::invalid_input("path is not valid UTF-8"))?
.as_bytes();
CString::new(bytes).map_err(|_| Error::invalid_input("path contains a NUL byte"))
}
#[cfg(test)]
mod tests {
use super::{
CORE_KERNELS, augment_construction_error, augment_with_data_dir, first_missing_core_kernel,
};
use crate::error::Error;
use std::path::Path;
#[test]
fn construction_message_assembly() {
let m = augment_with_data_dir("I/O error: nope", Path::new("/tmp/dd"), Some("bias.dat"));
assert!(
m.starts_with("I/O error: nope"),
"native cause kept up front: {m}"
);
assert!(m.contains("bias.dat"), "names the missing kernel: {m}");
assert!(m.contains("/tmp/dd"), "names the data directory: {m}");
assert!(
m.contains("download_data"),
"hints the download remedy: {m}"
);
assert!(
m.contains("EMPYREAN_DATA_DIR"),
"hints the env-var remedy: {m}"
);
let g = augment_with_data_dir("boom", Path::new("/tmp/dd"), None);
assert!(g.contains("/tmp/dd"));
assert!(!g.contains("download_data"));
}
#[test]
fn a_failed_download_gets_no_kernel_remedy() {
let base = "Data download failed: GET https://naif.jpl.nasa.gov/x.bpc: http status: 404";
let m = augment_with_data_dir(base, Path::new("/tmp/dd"), Some("bias.dat"));
assert!(m.starts_with(base), "native cause kept up front: {m}");
assert!(m.contains("/tmp/dd"), "names the data directory: {m}");
assert!(
!m.contains("download_data"),
"no circular provisioning remedy: {m}"
);
assert!(!m.contains("bias.dat"), "blames no kernel: {m}");
}
#[test]
fn a_failed_download_is_missing_data_even_with_core_kernels_present() {
let err = Error {
code: -1,
message: "Data download failed: GET https://naif.jpl.nasa.gov/pub/naif/\
generic_kernels/pck/earth_620120_260806.bpc: http status: 404"
.to_string(),
missing_data_files: Vec::new(),
};
let out = augment_construction_error(err, Some(Path::new("/nonexistent-dir")));
assert_eq!(
out.code, -2,
"a failed acquisition is missing-data regardless of the probe: {}",
out.message
);
}
#[test]
fn missing_core_kernel_probe() {
let tmp = std::env::temp_dir().join(format!("empyrean-probe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
assert_eq!(first_missing_core_kernel(&tmp), Some(CORE_KERNELS[0]));
for k in CORE_KERNELS {
std::fs::write(tmp.join(k), b"x").unwrap();
}
assert_eq!(first_missing_core_kernel(&tmp), None);
std::fs::remove_file(tmp.join("sb441-n16.bsp")).unwrap();
assert_eq!(first_missing_core_kernel(&tmp), Some("sb441-n16.bsp"));
let _ = std::fs::remove_dir_all(&tmp);
}
}
#[cfg(test)]
mod offline_floor_tests {
use super::{
Context, DataDirOptions, DataTier, OFFLINE_ENV, apply_offline_floor, download_data,
offline_env_is_set, offline_floor_is_active, refuse_download_under_offline_floor,
};
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_offline<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prior = std::env::var(OFFLINE_ENV).ok();
unsafe {
match value {
Some(v) => std::env::set_var(OFFLINE_ENV, v),
None => std::env::remove_var(OFFLINE_ENV),
}
}
let out = f();
unsafe {
match prior {
Some(v) => std::env::set_var(OFFLINE_ENV, v),
None => std::env::remove_var(OFFLINE_ENV),
}
}
out
}
#[test]
fn the_defaults_are_todays_behaviour() {
let d = DataDirOptions::default();
assert!(d.refresh, "the default acquires kernels");
assert_eq!(d.tier, DataTier::Standard);
assert_eq!(DataTier::default(), DataTier::Standard);
}
#[test]
fn the_floor_never_turns_the_network_on() {
with_offline(None, || {
assert!(apply_offline_floor(true), "unset + true = true");
assert!(!apply_offline_floor(false), "unset + false = false");
});
with_offline(Some("1"), || {
assert!(!apply_offline_floor(true), "set + true = floored to false");
assert!(!apply_offline_floor(false), "set + false = false");
});
}
#[test]
fn only_the_exact_value_one_sets_the_floor() {
for v in ["0", "true", "yes", "", "1 ", "01"] {
with_offline(Some(v), || {
assert!(
!offline_env_is_set(),
"{OFFLINE_ENV}={v:?} must not assert the floor"
);
assert!(apply_offline_floor(true), "{OFFLINE_ENV}={v:?}");
});
}
with_offline(Some("1"), || {
assert!(offline_env_is_set());
});
}
#[test]
fn the_older_constructor_takes_the_same_floored_path() {
with_offline(Some("1"), || {
assert!(
!apply_offline_floor(DataDirOptions::default().refresh),
"the floor downgrades the default `refresh: true` the old constructor uses"
);
assert!(offline_floor_is_active());
});
with_offline(None, || {
assert!(
apply_offline_floor(DataDirOptions::default().refresh),
"unset, the old constructor still refreshes"
);
});
}
#[test]
fn provisioning_is_refused_under_the_floor() {
with_offline(None, || {
assert!(
refuse_download_under_offline_floor().is_ok(),
"unset: provisioning proceeds"
);
});
with_offline(Some("1"), || {
let err = refuse_download_under_offline_floor()
.expect_err("the floor must refuse a download-only call");
assert!(
err.message.contains(OFFLINE_ENV),
"names the variable: {err}"
);
assert!(
err.message.contains("refresh: false") || err.message.contains("refresh=False"),
"points at the offline construction path: {err}"
);
});
with_offline(Some("0"), || {
assert!(
refuse_download_under_offline_floor().is_ok(),
"only the exact value 1 asserts the floor"
);
});
}
fn empty_scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"empyrean-offline-{}-{}-{tag}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create scratch data dir");
dir
}
fn entries(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir).map(|d| d.count()).unwrap_or(0)
}
#[test]
fn the_older_constructor_is_floored_end_to_end() {
let dir = empty_scratch("ctor");
let started = std::time::Instant::now();
let err = with_offline(Some("1"), || {
Context::from_data_dir(Some(&dir))
.err()
.expect("an empty directory under the floor cannot produce a context")
});
let elapsed = started.elapsed();
assert_eq!(
err.code, -2,
"a floored construction is a missing-data failure: {err}"
);
assert!(
!err.missing_data_files().is_empty(),
"the floored path must carry the structured file list, not just prose: {err}"
);
assert_eq!(
entries(&dir),
0,
"the floor must not have downloaded anything into {}",
dir.display()
);
assert!(
elapsed < std::time::Duration::from_secs(20),
"the floored constructor resolved in {elapsed:?} — that is long enough to \
have gone to the network"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn provisioning_is_refused_through_the_public_function() {
let dir = empty_scratch("download");
let err = with_offline(Some("1"), || {
download_data(Some(&dir)).expect_err("provisioning under the floor must refuse")
});
assert!(
err.message.contains(OFFLINE_ENV),
"the refusal must name the variable: {err}"
);
assert_eq!(
entries(&dir),
0,
"nothing may be fetched into {}",
dir.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod data_dir_shape_tests {
use super::{augment_construction_error, augment_with_unusable_data_dir, inspect_data_dir};
use crate::error::Error;
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"empyrean-datadir-{}-{}-{tag}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_resolving_or_absent_path_is_not_a_directory_level_fault() {
let root = scratch("ok");
assert!(inspect_data_dir(&root).is_ok(), "a real directory resolves");
assert!(
inspect_data_dir(&root.join("not-created-yet")).is_ok(),
"an absent path is unprovisioned, not obstructed"
);
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn a_symlink_to_a_real_directory_resolves() {
let root = scratch("goodlink");
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let link = root.join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(inspect_data_dir(&link).is_ok());
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_names_the_link_not_a_kernel() {
let root = scratch("dangling");
let link = root.join("data");
std::os::unix::fs::symlink(root.join("nowhere"), &link).unwrap();
let bad = inspect_data_dir(&link).expect_err("a dangling link is not a usable data dir");
assert_eq!(
bad.symlink_target.as_deref(),
Some(root.join("nowhere").as_path()),
"the link target is read and reported"
);
let msg =
augment_with_unusable_data_dir("I/O error: File exists (os error 17)", &link, &bad);
assert!(
msg.starts_with("I/O error: File exists (os error 17)"),
"the native cause stays up front: {msg}"
);
assert!(
msg.contains("symbolic link"),
"names the shape of the fault: {msg}"
);
assert!(
msg.contains("nowhere"),
"names the unresolved target: {msg}"
);
assert!(
!msg.contains("de440.bsp"),
"no kernel may be blamed for a directory-level fault: {msg}"
);
assert!(
!msg.contains("download_data"),
"download_data cannot repair a broken link: {msg}"
);
let err = augment_construction_error(
Error::invalid_input("I/O error: File exists (os error 17)"),
Some(&link),
);
assert!(!err.message.contains("de440.bsp"), "{}", err.message);
assert!(err.message.contains("symbolic link"), "{}", err.message);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_regular_file_at_the_data_dir_path_is_named() {
let root = scratch("file");
let path = root.join("data");
std::fs::write(&path, b"not a directory").unwrap();
let bad = inspect_data_dir(&path).expect_err("a file is not a usable data dir");
assert!(bad.symlink_target.is_none());
let msg = augment_with_unusable_data_dir("boom", &path, &bad);
assert!(msg.contains("is not a directory"), "{msg}");
assert!(!msg.contains("de440.bsp"), "{msg}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_older_constructors_error_builder_carries_the_file_list() {
let dir = scratch("payload");
let c_dir = std::ffi::CString::new(dir.to_str().unwrap()).unwrap();
let options = empyrean_sys::EmpyreanDataDirOptions {
refresh: empyrean_sys::EMPYREAN_DATA_REFRESH_OFF,
tier: empyrean_sys::EMPYREAN_DATA_TIER_STANDARD,
};
let raw =
unsafe { empyrean_sys::empyrean_context_from_data_dir_with(c_dir.as_ptr(), &options) };
assert!(
raw.is_null(),
"an empty directory cannot resolve the Standard tier offline"
);
let err = super::construction_error(Some(&dir));
assert!(
!err.missing_data_files().is_empty(),
"the older constructor must carry the file list too: {err}"
);
assert_eq!(
err.code, -2,
"a named data shortfall is the missing-data category: {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}