use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Convention {
Unix,
Windows,
}
#[cfg(windows)]
pub const NATIVE: Convention = Convention::Windows;
#[cfg(not(windows))]
pub const NATIVE: Convention = Convention::Unix;
pub const OVERRIDE_VAR: &str = "PHOSPHOR_HOME";
const WINDOWS_DIR: &str = "phosphor";
const UNIX_DIR: &str = ".phosphor";
const LOCAL_SESSIONS: &str = "sessions";
pub fn app_dir() -> Option<PathBuf> {
app_dir_in(NATIVE, |key| std::env::var(key).ok())
}
pub fn app_dir_in(convention: Convention, env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
let var = |key: &str| {
env(key)
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
};
if let Some(dir) = var(OVERRIDE_VAR) {
return Some(dir);
}
match convention {
Convention::Unix => Some(var("HOME")?.join(UNIX_DIR)),
Convention::Windows => var("APPDATA")
.or_else(|| var("USERPROFILE").map(|p| p.join("AppData").join("Roaming")))
.map(|p| p.join(WINDOWS_DIR)),
}
}
pub fn preset_dir() -> Option<PathBuf> {
app_dir().map(|dir| dir.join("presets"))
}
pub fn session_dir() -> Option<PathBuf> {
app_dir().map(|dir| dir.join(LOCAL_SESSIONS))
}
pub fn session_prompt_dir() -> String {
session_prompt_dir_from(Path::new(LOCAL_SESSIONS).is_dir(), session_dir())
}
pub fn session_prompt_dir_from(local_exists: bool, sessions: Option<PathBuf>) -> String {
let local = format!("{LOCAL_SESSIONS}/");
if local_exists {
return local;
}
match sessions {
Some(dir) => format!("{}{}", dir.display(), std::path::MAIN_SEPARATOR),
None => local,
}
}
pub fn find_session(input: &Path) -> PathBuf {
find_session_in(input, app_dir().as_deref())
}
pub fn find_session_in(input: &Path, app: Option<&Path>) -> PathBuf {
if input.is_absolute() || input.exists() {
return input.to_path_buf();
}
let Some(app) = app else {
return input.to_path_buf();
};
for base in [app.to_path_buf(), app.join(LOCAL_SESSIONS)] {
let candidate = base.join(input);
if candidate.exists() {
return candidate;
}
}
input.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key| {
vars.iter()
.find(|(name, _)| *name == key)
.map(|(_, value)| (*value).to_string())
}
}
#[test]
fn unix_resolves_to_the_dot_directory_it_always_has() {
let vars = env(&[("HOME", "/home/player")]);
assert_eq!(
app_dir_in(Convention::Unix, &vars),
Some(PathBuf::from("/home/player/.phosphor"))
);
assert_eq!(
app_dir_in(Convention::Unix, &vars).map(|d| d.join("presets")),
Some(PathBuf::from("/home/player/.phosphor/presets"))
);
}
#[test]
fn windows_resolves_under_appdata() {
let vars = env(&[
("APPDATA", r"C:\Users\player\AppData\Roaming"),
("USERPROFILE", r"C:\Users\player"),
]);
assert_eq!(
app_dir_in(Convention::Windows, &vars),
Some(PathBuf::from(r"C:\Users\player\AppData\Roaming").join("phosphor"))
);
}
#[test]
fn windows_falls_back_to_the_user_profile() {
let vars = env(&[("USERPROFILE", r"C:\Users\player")]);
assert_eq!(
app_dir_in(Convention::Windows, &vars),
Some(
PathBuf::from(r"C:\Users\player")
.join("AppData")
.join("Roaming")
.join("phosphor")
)
);
}
#[test]
fn a_windows_environment_without_home_still_resolves() {
let vars = env(&[
("APPDATA", r"C:\Users\player\AppData\Roaming"),
("USERPROFILE", r"C:\Users\player"),
]);
assert_eq!(vars("HOME"), None, "this test is about HOME being absent");
assert!(
app_dir_in(Convention::Windows, &vars).is_some(),
"presets would be silently discarded"
);
}
#[test]
fn windows_ignores_home() {
let vars = env(&[("HOME", "/c/Users/player")]);
assert_eq!(app_dir_in(Convention::Windows, &vars), None);
}
#[test]
fn the_override_wins_on_both_platforms() {
let vars = env(&[
(OVERRIDE_VAR, "/tmp/scratch-phosphor"),
("HOME", "/home/player"),
("APPDATA", r"C:\Users\player\AppData\Roaming"),
]);
for convention in [Convention::Unix, Convention::Windows] {
assert_eq!(
app_dir_in(convention, &vars),
Some(PathBuf::from("/tmp/scratch-phosphor")),
"{convention:?} did not honour {OVERRIDE_VAR}"
);
}
}
#[test]
fn a_blank_variable_is_not_a_home_directory() {
for blank in ["", " "] {
assert_eq!(app_dir_in(Convention::Unix, env(&[("HOME", blank)])), None);
assert_eq!(
app_dir_in(Convention::Windows, env(&[("APPDATA", blank)])),
None
);
assert_eq!(
app_dir_in(Convention::Unix, env(&[(OVERRIDE_VAR, blank), ("HOME", "/h")])),
Some(PathBuf::from("/h/.phosphor")),
"a blank override swallowed the real home directory"
);
}
}
#[test]
fn an_empty_environment_resolves_to_nothing() {
for convention in [Convention::Unix, Convention::Windows] {
assert_eq!(app_dir_in(convention, env(&[])), None);
}
}
#[test]
fn the_prompt_prefers_a_checkouts_own_sessions_directory() {
let sessions = PathBuf::from("/home/player/.phosphor").join("sessions");
assert_eq!(session_prompt_dir_from(true, Some(sessions.clone())), "sessions/");
assert_eq!(
session_prompt_dir_from(false, Some(sessions.clone())),
format!("{}{}", sessions.display(), std::path::MAIN_SEPARATOR)
);
assert_eq!(session_prompt_dir_from(false, None), "sessions/");
}
#[test]
fn opening_falls_back_to_the_application_directory() {
let root = std::env::temp_dir().join(format!("phosphor-paths-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let app = root.join("app");
std::fs::create_dir_all(app.join("sessions")).unwrap();
std::fs::write(app.join("sessions").join("take3.phos"), "{}").unwrap();
assert_eq!(
find_session_in(Path::new("sessions/take3.phos"), Some(&app)),
app.join("sessions").join("take3.phos")
);
assert_eq!(
find_session_in(Path::new("take3.phos"), Some(&app)),
app.join("sessions").join("take3.phos")
);
assert_eq!(
find_session_in(Path::new("nowhere.phos"), Some(&app)),
PathBuf::from("nowhere.phos")
);
let absolute = root.join("elsewhere.phos");
assert_eq!(find_session_in(&absolute, Some(&app)), absolute);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn the_working_directory_wins_over_the_application_directory() {
let root = std::env::temp_dir().join(format!("phosphor-paths-cwd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let app = root.join("app");
std::fs::create_dir_all(app.join("sessions")).unwrap();
std::fs::write(app.join("sessions").join("Cargo.toml"), "{}").unwrap();
assert!(Path::new("Cargo.toml").exists(), "this test needs a file in the working directory");
assert_eq!(
find_session_in(Path::new("Cargo.toml"), Some(&app)),
PathBuf::from("Cargo.toml")
);
let _ = std::fs::remove_dir_all(&root);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env(vars: &[(&str, &str)], body: impl FnOnce()) {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
const KEYS: [&str; 4] = [OVERRIDE_VAR, "HOME", "APPDATA", "USERPROFILE"];
let saved: Vec<(&str, Option<String>)> =
KEYS.iter().map(|k| (*k, std::env::var(k).ok())).collect();
for key in KEYS {
std::env::remove_var(key);
}
for (key, value) in vars {
std::env::set_var(key, value);
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
for (key, value) in saved {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
drop(guard);
if let Err(payload) = result {
std::panic::resume_unwind(payload);
}
}
#[test]
#[cfg(unix)]
fn the_process_environment_reaches_app_dir() {
with_env(&[("HOME", "/home/pinned")], || {
assert_eq!(app_dir(), Some(PathBuf::from("/home/pinned/.phosphor")));
assert_eq!(
preset_dir(),
Some(PathBuf::from("/home/pinned/.phosphor/presets"))
);
assert_eq!(
session_dir(),
Some(PathBuf::from("/home/pinned/.phosphor/sessions"))
);
assert_eq!(
crate::preset::default_dir(),
Some(PathBuf::from("/home/pinned/.phosphor/presets")),
"the preset bank moved out of ~/.phosphor/presets"
);
});
}
#[test]
fn an_unset_process_environment_gives_no_directory() {
with_env(&[], || {
assert_eq!(app_dir(), None);
assert_eq!(preset_dir(), None);
assert_eq!(crate::preset::default_dir(), None);
});
}
#[test]
fn the_process_environment_honours_the_override() {
with_env(&[(OVERRIDE_VAR, "/tmp/pinned-phosphor"), ("HOME", "/home/pinned")], || {
assert_eq!(app_dir(), Some(PathBuf::from("/tmp/pinned-phosphor")));
});
}
#[test]
fn the_native_convention_matches_the_host() {
#[cfg(windows)]
assert_eq!(NATIVE, Convention::Windows);
#[cfg(not(windows))]
assert_eq!(NATIVE, Convention::Unix);
}
}