use process_path::get_dylib_path;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Data directory does not exist")]
DirectoryDoesNotExist,
#[error("Could not set data directory")]
SetFailed,
#[error(
"No writeable data directory: {detail}. Set SATKIT_DATA to a directory satkit may write to"
)]
NoWriteableDirectory { detail: Box<str> },
}
pub type Result<T> = std::result::Result<T, Error>;
const DIR_NAME: &str = "satkit-data";
static EXPLICIT: Mutex<Option<PathBuf>> = Mutex::new(None);
static EXTRA_SEARCH: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
static WRITE_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
#[derive(Debug, Clone, Default)]
pub struct Env {
pub satkit_data: Option<PathBuf>,
pub dylib_dir: Option<PathBuf>,
pub home: Option<PathBuf>,
pub xdg_data_home: Option<PathBuf>,
pub local_app_data: Option<PathBuf>,
pub os: &'static str,
}
impl Env {
pub fn current() -> Self {
let var = |k: &str| {
std::env::var_os(k)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
};
Self {
satkit_data: var("SATKIT_DATA"),
dylib_dir: get_dylib_path().and_then(|p| Path::new(&p).parent().map(Path::to_path_buf)),
home: var("HOME").or_else(|| var("USERPROFILE")),
xdg_data_home: var("XDG_DATA_HOME"),
local_app_data: var("LOCALAPPDATA"),
os: std::env::consts::OS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub search: Vec<PathBuf>,
pub write: Option<PathBuf>,
}
fn user_data_dir(env: &Env) -> Option<PathBuf> {
match env.os {
"macos" => env
.home
.as_ref()
.map(|h| h.join("Library").join("Application Support").join(DIR_NAME)),
"windows" => env
.local_app_data
.as_ref()
.map(|d| d.join(DIR_NAME))
.or_else(|| {
env.home
.as_ref()
.map(|h| h.join("AppData").join("Local").join(DIR_NAME))
}),
_ => env
.xdg_data_home
.as_ref()
.map(|d| d.join(DIR_NAME))
.or_else(|| {
env.home
.as_ref()
.map(|h| h.join(".local").join("share").join(DIR_NAME))
}),
}
}
pub fn resolve(env: &Env, explicit: Option<&Path>, extra: &[PathBuf]) -> Resolved {
let mut search: Vec<PathBuf> = Vec::new();
let mut push = |p: PathBuf| {
if !search.contains(&p) {
search.push(p);
}
};
if let Some(d) = &env.satkit_data {
push(d.clone());
}
if let Some(d) = explicit {
push(d.to_path_buf());
}
for d in extra {
push(d.clone());
}
if let Some(dylib) = &env.dylib_dir {
push(dylib.join(DIR_NAME));
if let Some(site_packages) = dylib.parent() {
push(site_packages.join("satkit_data").join("data"));
}
}
let user_dir = user_data_dir(env);
if let Some(d) = &user_dir {
push(d.clone());
}
if let Some(h) = &env.home {
push(h.join(".satkit-data"));
}
if env.os != "windows" {
push(PathBuf::from("/usr/share").join(DIR_NAME));
}
if env.os == "macos" {
push(PathBuf::from("/Library/Application Support").join(DIR_NAME));
}
let write = env
.satkit_data
.clone()
.or_else(|| explicit.map(Path::to_path_buf))
.or(user_dir);
Resolved { search, write }
}
fn resolved_now() -> Resolved {
let explicit = EXPLICIT.lock().unwrap_or_else(|e| e.into_inner()).clone();
let extra = EXTRA_SEARCH
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
resolve(&Env::current(), explicit.as_deref(), &extra)
}
pub fn search_dirs() -> Vec<PathBuf> {
resolved_now().search
}
pub fn find_file(name: &str) -> Option<PathBuf> {
search_dirs()
.into_iter()
.map(|d| d.join(name))
.find(|p| p.is_file())
}
pub fn path_for(name: &str) -> Result<PathBuf> {
match find_file(name) {
Some(p) => Ok(p),
None => Ok(datadir()?.join(name)),
}
}
pub fn ensure_writable(dir: &Path) -> std::io::Result<()> {
if !dir.is_dir() {
std::fs::create_dir_all(dir)?;
}
let probe = dir.join(format!(
".satkit-write-probe.{}.{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0)
));
std::fs::write(&probe, b"")?;
let _ = std::fs::remove_file(&probe);
Ok(())
}
fn no_write_dir_detail(env: &Env, search: &[PathBuf]) -> String {
let missing = match env.os {
"windows" => "LOCALAPPDATA and USERPROFILE are unset",
"macos" => "HOME is unset",
_ => "HOME and XDG_DATA_HOME are unset",
};
let tried: Vec<String> = search.iter().map(|p| p.display().to_string()).collect();
format!(
"SATKIT_DATA is unset and the platform user-data directory is unknown ({missing}); \
read-only search directories consulted: [{}]",
tried.join(", ")
)
}
pub fn add_search_dir(d: &Path) {
let mut v = EXTRA_SEARCH.lock().unwrap_or_else(|e| e.into_inner());
if !v.iter().any(|x| x == d) {
v.push(d.to_path_buf());
}
}
pub fn set_datadir(d: &Path) -> Result<()> {
if !d.is_dir() {
return Err(Error::DirectoryDoesNotExist);
}
*EXPLICIT.lock().unwrap_or_else(|e| e.into_inner()) = Some(d.to_path_buf());
*WRITE_DIR.lock().unwrap_or_else(|e| e.into_inner()) = None;
Ok(())
}
pub fn datadir() -> Result<PathBuf> {
let mut cache = WRITE_DIR.lock().unwrap_or_else(|e| e.into_inner());
if let Some(d) = cache.as_ref() {
return Ok(d.clone());
}
let resolved = resolved_now();
let Some(dir) = resolved.write else {
return Err(Error::NoWriteableDirectory {
detail: no_write_dir_detail(&Env::current(), &resolved.search).into_boxed_str(),
});
};
ensure_writable(&dir).map_err(|e| Error::NoWriteableDirectory {
detail: format!(
"{} could not be created or is not writable ({e})",
dir.display()
)
.into_boxed_str(),
})?;
*cache = Some(dir.clone());
Ok(dir)
}
pub fn has_ephemeris(dir: &Path) -> bool {
let Ok(rd) = std::fs::read_dir(dir) else {
return false;
};
rd.flatten()
.any(|e| is_ephemeris_name(&e.file_name().to_string_lossy()))
}
pub fn is_ephemeris_name(name: &str) -> bool {
(name.starts_with("linux_p") || name.starts_with("lnxp"))
&& name.rsplit('.').next().is_some_and(|ext| {
ext.len() == 3 && ext.starts_with('4') && ext.chars().all(|c| c.is_ascii_digit())
})
}
pub fn data_found() -> bool {
search_dirs().iter().any(|d| has_ephemeris(d))
}
#[deprecated(note = "use search_dirs()")]
pub fn testdirs() -> Vec<PathBuf> {
search_dirs()
}
#[cfg(test)]
mod tests {
use super::*;
fn env(os: &'static str) -> Env {
Env {
satkit_data: None,
dylib_dir: Some(PathBuf::from("/venv/lib/python3.13/site-packages/satkit")),
home: Some(PathBuf::from("/home/u")),
xdg_data_home: None,
local_app_data: None,
os,
}
}
#[test]
fn offline_resolve_macos_order_and_write_dir() {
let r = resolve(&env("macos"), None, &[]);
assert_eq!(
r.write.as_deref(),
Some(Path::new("/home/u/Library/Application Support/satkit-data"))
);
assert_eq!(
r.search,
vec![
PathBuf::from("/venv/lib/python3.13/site-packages/satkit/satkit-data"),
PathBuf::from("/venv/lib/python3.13/site-packages/satkit_data/data"),
PathBuf::from("/home/u/Library/Application Support/satkit-data"),
PathBuf::from("/home/u/.satkit-data"),
PathBuf::from("/usr/share/satkit-data"),
PathBuf::from("/Library/Application Support/satkit-data"),
]
);
}
#[test]
fn offline_resolve_linux_xdg() {
let mut e = env("linux");
let r = resolve(&e, None, &[]);
assert_eq!(
r.write.as_deref(),
Some(Path::new("/home/u/.local/share/satkit-data"))
);
assert!(!r.search.iter().any(|p| p.starts_with("/Library")));
e.xdg_data_home = Some(PathBuf::from("/xdg"));
let r = resolve(&e, None, &[]);
assert_eq!(r.write.as_deref(), Some(Path::new("/xdg/satkit-data")));
assert_eq!(r.search[2], PathBuf::from("/xdg/satkit-data"));
assert!(
r.search.contains(&PathBuf::from("/home/u/.satkit-data")),
"legacy read candidate"
);
}
#[test]
fn offline_resolve_windows_localappdata() {
let mut e = env("windows");
e.home = None;
e.local_app_data = Some(PathBuf::from(r"C:\Users\u\AppData\Local"));
let r = resolve(&e, None, &[]);
assert_eq!(
r.write.as_deref(),
Some(
Path::new(r"C:\Users\u\AppData\Local")
.join(DIR_NAME)
.as_path()
)
);
assert!(!r.search.iter().any(|p| p.starts_with("/usr/share")));
e.local_app_data = None;
let r = resolve(&e, None, &[]);
assert_eq!(r.write, None);
assert!(r
.search
.iter()
.all(|p| !p.ends_with("satkit-data") || p.starts_with("/venv")));
}
#[test]
fn offline_resolve_precedence_of_overrides() {
let mut e = env("linux");
e.satkit_data = Some(PathBuf::from("/data"));
let r = resolve(&e, Some(Path::new("/explicit")), &[PathBuf::from("/extra")]);
assert_eq!(
r.write.as_deref(),
Some(Path::new("/data")),
"SATKIT_DATA wins for writes"
);
assert_eq!(
&r.search[..3],
&[
PathBuf::from("/data"),
PathBuf::from("/explicit"),
PathBuf::from("/extra")
]
);
e.satkit_data = None;
let r = resolve(&e, Some(Path::new("/explicit")), &[]);
assert_eq!(
r.write.as_deref(),
Some(Path::new("/explicit")),
"set_datadir is the write dir when no env"
);
let r = resolve(&e, None, &[PathBuf::from("/extra")]);
assert_eq!(
r.write.as_deref(),
Some(Path::new("/home/u/.local/share/satkit-data")),
"extra search dirs never become the write dir"
);
}
#[test]
fn offline_resolve_no_home_means_no_write_dir() {
for os in ["linux", "macos", "windows"] {
let mut e = env(os);
e.home = None;
e.local_app_data = None;
e.xdg_data_home = None;
let r = resolve(&e, None, &[]);
assert_eq!(r.write, None, "{os}");
assert!(
!r.search.contains(&PathBuf::from(".")),
"{os}: CWD is never a candidate"
);
let detail = no_write_dir_detail(&e, &r.search);
assert!(detail.contains("SATKIT_DATA is unset"), "{detail}");
}
let mut e = env("linux");
e.home = None;
e.xdg_data_home = Some(PathBuf::from("/xdg"));
assert_eq!(
resolve(&e, None, &[]).write.as_deref(),
Some(Path::new("/xdg/satkit-data"))
);
}
#[test]
#[cfg(unix)]
fn offline_read_only_dir_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("satkit_ro_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let probe_would_fail = std::fs::write(dir.join(".rootcheck"), b"").is_err();
if probe_would_fail {
assert!(ensure_writable(&dir).is_err());
assert!(
ensure_writable(&dir.join("child")).is_err(),
"cannot create under a read-only directory"
);
}
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let _ = std::fs::remove_file(dir.join(".rootcheck"));
assert!(ensure_writable(&dir).is_ok());
assert!(
!std::fs::read_dir(&dir).unwrap().flatten().any(|e| e
.file_name()
.to_string_lossy()
.starts_with(".satkit-write-probe")),
"probe file is removed"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn no_writeable_error_mentions_satkit_data() {
let e = Error::NoWriteableDirectory { detail: "x".into() };
assert!(e.to_string().contains("SATKIT_DATA"));
}
#[test]
fn offline_ephemeris_names() {
assert!(is_ephemeris_name("linux_p1550p2650.440"));
assert!(is_ephemeris_name("lnxp1900p2053.421"));
assert!(!is_ephemeris_name("linux_p1550p2650.440.part"));
assert!(!is_ephemeris_name("EGM96.gfc"));
}
#[test]
fn datadir_resolves() {
let d = datadir();
println!("d = {:?}", d.as_ref().unwrap());
assert!(d.is_ok());
}
}