use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use concinnity_engine::StateTree;
pub(crate) fn tree_for_exe(exe: &Path, exe_dir: &Path) -> StateTree {
let content = state_dir_for_exe(exe_dir);
let writable = (!dir_is_writable(&content))
.then(|| per_user_state_dir(&app_name_from_exe(exe)))
.flatten();
match writable {
Some(dir) => StateTree::at(content).with_writable(dir),
None => StateTree::at(content),
}
}
pub(crate) fn state_dir_for_exe(exe_dir: &Path) -> PathBuf {
let in_app_bundle = exe_dir.file_name() == Some(OsStr::new("MacOS"))
&& exe_dir.parent().and_then(Path::file_name) == Some(OsStr::new("Contents"));
match exe_dir.parent() {
Some(contents) if in_app_bundle => contents.join("Resources"),
_ => exe_dir.to_path_buf(),
}
}
pub(crate) fn app_name_from_exe(exe: &Path) -> String {
exe.file_stem()
.and_then(OsStr::to_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("concinnity")
.to_string()
}
pub(crate) fn dir_is_writable(dir: &Path) -> bool {
if !dir.exists() {
return true;
}
let probe = dir.join(format!(".cn-write-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
pub(crate) fn per_user_state_dir(app: &str) -> Option<PathBuf> {
per_user_base().map(|base| base.join(app))
}
#[cfg(windows)]
fn per_user_base() -> Option<PathBuf> {
non_empty_env("LOCALAPPDATA")
.or_else(|| non_empty_env("APPDATA"))
.map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn per_user_base() -> Option<PathBuf> {
non_empty_env("HOME").map(|h| PathBuf::from(h).join("Library").join("Application Support"))
}
#[cfg(all(unix, not(target_os = "macos")))]
fn per_user_base() -> Option<PathBuf> {
non_empty_env("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| non_empty_env("HOME").map(|h| PathBuf::from(h).join(".local").join("share")))
}
#[cfg(any(windows, unix))]
fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
std::env::var_os(key).filter(|v| !v.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_layout_uses_the_executable_directory() {
let dir = Path::new("/apps/MyApp");
assert_eq!(state_dir_for_exe(dir), Path::new("/apps/MyApp"));
}
#[test]
fn macos_app_bundle_uses_resources() {
let dir = Path::new("/Applications/MyGame.app/Contents/MacOS");
assert_eq!(
state_dir_for_exe(dir),
Path::new("/Applications/MyGame.app/Contents/Resources")
);
}
#[test]
fn macos_like_path_not_in_bundle_stays_beside_exe() {
let dir = Path::new("/home/user/MacOS");
assert_eq!(state_dir_for_exe(dir), Path::new("/home/user/MacOS"));
}
#[test]
fn app_name_falls_back_when_stem_missing() {
assert_eq!(app_name_from_exe(Path::new("/apps/MyGame")), "MyGame");
assert_eq!(app_name_from_exe(Path::new("MyGame.exe")), "MyGame");
assert_eq!(app_name_from_exe(Path::new("/")), "concinnity");
}
#[test]
fn a_writable_install_keeps_one_folder() {
let tmp = concinnity_testing::TempTree::new();
let exe = tmp.path().join("MyGame");
let tree = tree_for_exe(&exe, tmp.path());
assert_eq!(tree.content_root(), tmp.path());
assert_eq!(tree.writable_root(), tmp.path());
assert_eq!(tree.data_dir(), tmp.path().join("data"));
assert_eq!(tree.saves_dir(), tmp.path().join("saves"));
}
#[test]
fn a_writable_dir_probes_true_and_leaves_nothing_behind() {
let tmp = concinnity_testing::TempTree::new();
assert!(dir_is_writable(tmp.path()));
assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
}
#[test]
fn a_missing_dir_is_treated_as_writable() {
let tmp = concinnity_testing::TempTree::new();
let missing = tmp.path().join("not-created-yet");
assert!(dir_is_writable(&missing));
}
#[test]
fn per_user_dir_appends_the_app_name_under_a_base() {
let dir = per_user_state_dir("MyGame").expect("a per-user base on the test host");
assert_eq!(dir.file_name().and_then(OsStr::to_str), Some("MyGame"));
assert!(dir.is_absolute());
}
}