use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
pub const DEFAULT_ORG: &str = "Orthic Labs";
#[cfg(feature = "test-mode")]
pub const QA_DATA_DIR_VAR: &str = "RIGHTKIT_QA_DATA_DIR";
#[cfg(feature = "test-mode")]
pub const QA_HOME_VAR: &str = "RIGHTKIT_QA_HOME";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AppDirsError {
#[error("cannot resolve the {what} folder: {why}")]
NoBase { what: &'static str, why: String },
#[error("{var} must be an absolute path without `..`: {}", .path.display())]
NotAbsolute { var: String, path: PathBuf },
#[error("{var} points at the real per-user folder {}; refusing", .path.display())]
RealFolder { var: String, path: PathBuf },
#[error("Android has no environment-based folder resolution: pass the Context's filesDir and cacheDir to AppDirs::injected")]
AndroidNeedsInjectedRoots,
#[error("injected {what} folder is unusable: {why}")]
BadInjectedRoot { what: &'static str, why: String },
#[error("{0}")]
Io(#[from] io::Error),
}
pub type Result<T> = std::result::Result<T, AppDirsError>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Os {
Windows,
MacOs,
Linux,
Android,
}
impl Os {
pub fn current() -> Os {
if cfg!(windows) {
Os::Windows
} else if cfg!(target_os = "android") {
Os::Android
} else if cfg!(target_os = "macos") {
Os::MacOs
} else {
Os::Linux
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AppId {
pub name: &'static str,
pub org: Option<&'static str>,
pub legacy_data_env: Option<&'static str>,
pub legacy_home_env: Option<&'static str>,
}
impl AppId {
pub const fn new(name: &'static str) -> AppId {
AppId {
name,
org: Some(DEFAULT_ORG),
legacy_data_env: None,
legacy_home_env: None,
}
}
pub const fn with_org(mut self, org: &'static str) -> AppId {
self.org = Some(org);
self
}
pub const fn without_org(mut self) -> AppId {
self.org = None;
self
}
pub const fn with_legacy_env(
mut self,
data: Option<&'static str>,
home: Option<&'static str>,
) -> AppId {
self.legacy_data_env = data;
self.legacy_home_env = home;
self
}
pub fn namespace(&self, dev: bool) -> String {
if dev {
format!("{}Dev", self.name)
} else {
self.name.to_string()
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Options {
pub os: Os,
pub dev: bool,
pub qa_overrides: bool,
pub test_root: Option<PathBuf>,
}
impl Options {
pub fn new(os: Os) -> Options {
Options {
os,
dev: false,
qa_overrides: false,
test_root: None,
}
}
pub fn current() -> Options {
Options {
os: Os::current(),
dev: cfg!(debug_assertions),
qa_overrides: cfg!(feature = "test-mode"),
test_root: if cfg!(test) {
Some(process_test_root())
} else {
None
},
}
}
pub fn dev(mut self, dev: bool) -> Options {
self.dev = dev;
self
}
pub fn qa_overrides(mut self, on: bool) -> Options {
self.qa_overrides = on;
self
}
pub fn test_root(mut self, root: Option<PathBuf>) -> Options {
self.test_root = root;
self
}
}
pub fn process_test_root() -> PathBuf {
std::env::temp_dir().join(format!("rightkit-test-{}", std::process::id()))
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AppDirs {
pub data: PathBuf,
pub local: PathBuf,
pub cache: PathBuf,
pub home: PathBuf,
pub isolated: bool,
}
impl AppDirs {
pub fn resolve(app: AppId) -> Result<AppDirs> {
AppDirs::resolve_with(&app, &Options::current(), &|k| std::env::var_os(k))
}
pub fn for_test(app: AppId) -> Result<AppDirs> {
let opts = Options::current().test_root(Some(process_test_root()));
AppDirs::resolve_with(&app, &opts, &|_| None)
}
pub fn resolve_with(
app: &AppId,
opts: &Options,
env: &dyn Fn(&str) -> Option<OsString>,
) -> Result<AppDirs> {
let ns = app.namespace(opts.dev);
if let Some(root) = &opts.test_root {
check_absolute(opts.os, "test root", root)?;
let r = root.join(&ns);
return Ok(AppDirs {
data: r.join("data"),
local: r.join("local"),
cache: r.join("cache"),
home: r.join("home"),
isolated: true,
});
}
if opts.os == Os::Android {
return Err(AppDirsError::AndroidNeedsInjectedRoots);
}
let bases = Bases::read(opts.os, env);
let (qa_data, qa_home) = if opts.qa_overrides {
read_qa(app, opts, env, &bases)?
} else {
(None, None)
};
if let Some(root) = qa_data {
let home = qa_home.unwrap_or_else(|| root.join("home"));
return Ok(AppDirs {
local: root.join("local"),
cache: root.join("cache"),
data: root,
home,
isolated: true,
});
}
let dirs = real_dirs(app, opts.os, &ns, env)?;
Ok(match qa_home {
Some(home) => AppDirs {
home,
isolated: true,
..dirs
},
None => dirs,
})
}
pub fn injected(
files_dir: impl Into<PathBuf>,
cache_dir: impl Into<PathBuf>,
) -> Result<AppDirs> {
AppDirs::injected_for(Os::current(), files_dir.into(), cache_dir.into())
}
pub fn injected_for(os: Os, files_dir: PathBuf, cache_dir: PathBuf) -> Result<AppDirs> {
let bad = |what: &'static str, why: &str| AppDirsError::BadInjectedRoot {
what,
why: why.to_string(),
};
for (what, path) in [("files", &files_dir), ("cache", &cache_dir)] {
if path.as_os_str().is_empty() {
return Err(bad(what, "empty"));
}
if !looks_absolute(os, path) {
return Err(bad(what, "must be an absolute path without `..`"));
}
}
let win = os == Os::Windows;
if inside_or_equal(&files_dir, &cache_dir, win)
|| inside_or_equal(&cache_dir, &files_dir, win)
{
return Err(bad("cache", "must not equal or nest with the files folder"));
}
Ok(AppDirs {
data: files_dir.clone(),
local: files_dir.clone(),
cache: cache_dir,
home: files_dir,
isolated: false,
})
}
pub fn create_all(&self) -> io::Result<()> {
for d in [&self.data, &self.local, &self.cache, &self.home] {
std::fs::create_dir_all(d)?;
}
Ok(())
}
}
struct Bases {
data: Option<PathBuf>,
local: Option<PathBuf>,
cache: Option<PathBuf>,
home: Option<PathBuf>,
}
fn nonempty(env: &dyn Fn(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
env(key)
.filter(|v| !v.to_string_lossy().trim().is_empty())
.map(PathBuf::from)
}
fn absolute_env(os: Os, env: &dyn Fn(&str) -> Option<OsString>, key: &str) -> Option<PathBuf> {
nonempty(env, key).filter(|p| looks_absolute(os, p))
}
impl Bases {
fn read(os: Os, env: &dyn Fn(&str) -> Option<OsString>) -> Bases {
match os {
Os::Windows => Bases {
data: absolute_env(os, env, "APPDATA"),
local: absolute_env(os, env, "LOCALAPPDATA"),
cache: absolute_env(os, env, "LOCALAPPDATA"),
home: absolute_env(os, env, "USERPROFILE"),
},
Os::MacOs => {
let home = absolute_env(os, env, "HOME");
let support = home.as_ref().map(|h| h.join("Library/Application Support"));
Bases {
data: support.clone(),
local: support,
cache: home.as_ref().map(|h| h.join("Library/Caches")),
home,
}
}
Os::Android => Bases {
data: None,
local: None,
cache: None,
home: None,
},
Os::Linux => {
let home = absolute_env(os, env, "HOME");
let data = absolute_env(os, env, "XDG_DATA_HOME")
.or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let cache = absolute_env(os, env, "XDG_CACHE_HOME")
.or_else(|| home.as_ref().map(|h| h.join(".cache")));
Bases {
local: data.clone(),
data,
cache,
home,
}
}
}
}
}
fn app_dir(base: &Path, app: &AppId, ns: &str) -> PathBuf {
match app.org {
Some(org) => base.join(org).join(ns),
None => base.join(ns),
}
}
fn missing(os: Os, what: &'static str) -> AppDirsError {
let why = match os {
Os::Windows => match what {
"data" => "APPDATA is not set to an absolute path",
"home" => "USERPROFILE is not set to an absolute path",
_ => "LOCALAPPDATA is not set to an absolute path",
},
_ => "HOME is not set to an absolute path",
};
AppDirsError::NoBase {
what,
why: why.to_string(),
}
}
fn real_dirs(
app: &AppId,
os: Os,
ns: &str,
env: &dyn Fn(&str) -> Option<OsString>,
) -> Result<AppDirs> {
let b = Bases::read(os, env);
let data = b.data.ok_or_else(|| missing(os, "data"))?;
let local = b.local.ok_or_else(|| missing(os, "local"))?;
let cache = b.cache.ok_or_else(|| missing(os, "cache"))?;
let home = b.home.ok_or_else(|| missing(os, "home"))?;
let cache = if os == Os::Windows {
app_dir(&cache, app, ns).join("Cache")
} else {
app_dir(&cache, app, ns)
};
Ok(AppDirs {
data: app_dir(&data, app, ns),
local: app_dir(&local, app, ns),
cache,
home,
isolated: false,
})
}
#[cfg(not(feature = "test-mode"))]
fn read_qa(
_app: &AppId,
_opts: &Options,
_env: &dyn Fn(&str) -> Option<OsString>,
_bases: &Bases,
) -> Result<(Option<PathBuf>, Option<PathBuf>)> {
Ok((None, None))
}
#[cfg(feature = "test-mode")]
fn read_qa(
app: &AppId,
opts: &Options,
env: &dyn Fn(&str) -> Option<OsString>,
bases: &Bases,
) -> Result<(Option<PathBuf>, Option<PathBuf>)> {
let pick = |names: [Option<&'static str>; 2]| -> Option<(&'static str, PathBuf)> {
names
.into_iter()
.flatten()
.find_map(|n| nonempty(env, n).map(|p| (n, p)))
};
let data = pick([Some(QA_DATA_DIR_VAR), app.legacy_data_env]);
let home = pick([Some(QA_HOME_VAR), app.legacy_home_env]);
if data.is_none() && home.is_none() {
return Ok((None, None));
}
let mut inside: Vec<PathBuf> = Vec::new();
for base in [&bases.data, &bases.local, &bases.cache]
.into_iter()
.flatten()
{
for ns in [app.name.to_string(), format!("{}Dev", app.name)] {
for org in [app.org, None] {
let probe = AppId { org, ..app.clone() };
inside.push(app_dir(base, &probe, &ns));
}
}
}
let win = opts.os == Os::Windows;
let check = |var: &str, path: PathBuf, home_var: bool| -> Result<PathBuf> {
check_absolute(opts.os, var, &path)?;
let hit = inside.iter().any(|real| inside_or_equal(&path, real, win))
|| (home_var
&& bases.home.as_ref().is_some_and(|h| {
inside_or_equal(&path, h, win) && inside_or_equal(h, &path, win)
}));
if hit {
return Err(AppDirsError::RealFolder {
var: var.to_string(),
path,
});
}
Ok(path)
};
let data = data.map(|(v, p)| check(v, p, false)).transpose()?;
let home = home.map(|(v, p)| check(v, p, true)).transpose()?;
Ok((data, home))
}
fn check_absolute(os: Os, var: &str, path: &Path) -> Result<()> {
if looks_absolute(os, path) {
Ok(())
} else {
Err(AppDirsError::NotAbsolute {
var: var.to_string(),
path: path.to_path_buf(),
})
}
}
fn looks_absolute(os: Os, path: &Path) -> bool {
let s = path.to_string_lossy();
let rooted = match os {
Os::Windows => {
let b = s.as_bytes();
s.starts_with("\\\\")
|| (b.len() >= 3
&& b[0].is_ascii_alphabetic()
&& b[1] == b':'
&& (b[2] == b'\\' || b[2] == b'/'))
}
_ => s.starts_with('/'),
};
rooted
&& !components(&s, os == Os::Windows, false)
.iter()
.any(|c| c == "..")
}
fn components(s: &str, windows: bool, lower: bool) -> Vec<String> {
s.split(|c: char| c == '/' || (windows && c == '\\'))
.filter(|c| !c.is_empty())
.map(|c| {
if lower {
c.to_lowercase()
} else {
c.to_string()
}
})
.collect()
}
fn inside_or_equal(path: &Path, base: &Path, windows: bool) -> bool {
let seps = windows || cfg!(windows);
let p = components(&path.to_string_lossy(), seps, windows);
let b = components(&base.to_string_lossy(), seps, windows);
p.len() >= b.len() && p[..b.len()] == b[..]
}
#[cfg(test)]
mod tests {
use super::*;
fn env_of(pairs: &[(&'static str, &'static str)]) -> impl Fn(&str) -> Option<OsString> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k: &str| {
pairs
.iter()
.find(|(n, _)| n == k)
.map(|(_, v)| OsString::from(v))
}
}
const WIN: &[(&str, &str)] = &[
("APPDATA", r"C:\Users\a\AppData\Roaming"),
("LOCALAPPDATA", r"C:\Users\a\AppData\Local"),
("USERPROFILE", r"C:\Users\a"),
];
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn same(actual: &Path, expected: &str) {
assert_eq!(
components(&actual.to_string_lossy(), true, false),
components(expected, true, false),
"{} vs {expected}",
actual.display()
);
}
#[test]
fn windows_mapping() {
let d = AppDirs::resolve_with(
&AppId::new("ViewRight"),
&Options::new(Os::Windows),
&env_of(WIN),
)
.unwrap();
same(&d.data, r"C:\Users\a\AppData\Roaming\Orthic Labs\ViewRight");
same(&d.local, r"C:\Users\a\AppData\Local\Orthic Labs\ViewRight");
same(
&d.cache,
r"C:\Users\a\AppData\Local\Orthic Labs\ViewRight\Cache",
);
same(&d.home, r"C:\Users\a");
assert!(!d.isolated);
}
#[test]
fn macos_mapping() {
let d = AppDirs::resolve_with(
&AppId::new("MailRight"),
&Options::new(Os::MacOs),
&env_of(&[("HOME", "/Users/a")]),
)
.unwrap();
same(
&d.data,
"/Users/a/Library/Application Support/Orthic Labs/MailRight",
);
assert_eq!(d.local, d.data);
same(&d.cache, "/Users/a/Library/Caches/Orthic Labs/MailRight");
same(&d.home, "/Users/a");
}
#[test]
fn linux_mapping_uses_xdg_when_set_and_falls_back_to_home() {
let app = AppId::new("CutRight");
let o = Options::new(Os::Linux);
let d = AppDirs::resolve_with(&app, &o, &env_of(&[("HOME", "/home/a")])).unwrap();
same(&d.data, "/home/a/.local/share/Orthic Labs/CutRight");
assert_eq!(d.local, d.data);
same(&d.cache, "/home/a/.cache/Orthic Labs/CutRight");
let d = AppDirs::resolve_with(
&app,
&o,
&env_of(&[
("HOME", "/home/a"),
("XDG_DATA_HOME", "/x/data"),
("XDG_CACHE_HOME", "/x/cache"),
]),
)
.unwrap();
same(&d.data, "/x/data/Orthic Labs/CutRight");
same(&d.cache, "/x/cache/Orthic Labs/CutRight");
let d = AppDirs::resolve_with(
&app,
&o,
&env_of(&[("HOME", "/home/a"), ("XDG_DATA_HOME", "rel")]),
)
.unwrap();
same(&d.data, "/home/a/.local/share/Orthic Labs/CutRight");
}
#[test]
fn android_has_no_environment_mapping_and_points_at_the_injected_constructor() {
let env = env_of(&[("HOME", "/data/user/0/x"), ("XDG_DATA_HOME", "/x")]);
let err =
AppDirs::resolve_with(&AppId::new("A"), &Options::new(Os::Android), &env).unwrap_err();
assert!(matches!(err, AppDirsError::AndroidNeedsInjectedRoots));
assert!(err.to_string().contains("AppDirs::injected"), "{err}");
let o = Options::new(Os::Android).test_root(Some(p("/data/local/tmp/rightkit-test")));
assert!(
AppDirs::resolve_with(&AppId::new("A"), &o, &env_of(&[]))
.unwrap()
.isolated
);
}
#[test]
fn injected_roots_map_files_and_cache_without_a_namespace() {
let d = AppDirs::injected_for(
Os::Android,
p("/data/user/0/com.orthiclabs.heardright/files"),
p("/data/user/0/com.orthiclabs.heardright/cache"),
)
.unwrap();
same(&d.data, "/data/user/0/com.orthiclabs.heardright/files");
assert_eq!(d.local, d.data);
assert_eq!(d.home, d.data);
same(&d.cache, "/data/user/0/com.orthiclabs.heardright/cache");
assert!(!d.isolated);
let (f, c) = if cfg!(windows) {
(r"C:\app\files", r"C:\app\cache")
} else {
("/app/files", "/app/cache")
};
let d = AppDirs::injected(f, c).unwrap();
assert_eq!((d.data, d.cache), (p(f), p(c)));
}
#[test]
fn injected_roots_refuse_unusable_input() {
let ok = |f: &str, c: &str| AppDirs::injected_for(Os::Android, p(f), p(c));
for (f, c) in [
("", "/c"),
("/f", ""),
("files", "/c"),
("/f", "cache"),
("/f/../g", "/c"),
("/f", "/c/.."),
("/same", "/same"),
("/f", "/f/cache"),
("/f/files", "/f"),
] {
assert!(
matches!(ok(f, c), Err(AppDirsError::BadInjectedRoot { .. })),
"{f:?} {c:?}"
);
}
assert!(ok("/data/files", "/data/files-cache").is_ok());
assert!(
AppDirs::injected_for(Os::Windows, p(r"C:\a\Files"), p(r"c:\a\files\cache")).is_err()
);
}
#[test]
fn injected_roots_work_with_create_all() {
let root = process_test_root().join("InjectedCreateAll");
let d = AppDirs::injected(root.join("files"), root.join("cache")).unwrap();
d.create_all().unwrap();
assert!(d.data.is_dir() && d.cache.is_dir());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn no_org_keeps_a_flat_layout() {
let d = AppDirs::resolve_with(
&AppId::new("ViewRight").without_org(),
&Options::new(Os::Linux),
&env_of(&[("HOME", "/home/a")]),
)
.unwrap();
same(&d.data, "/home/a/.local/share/ViewRight");
}
#[test]
fn dev_build_uses_the_dev_namespace() {
let d = AppDirs::resolve_with(
&AppId::new("ViewRight"),
&Options::new(Os::Linux).dev(true),
&env_of(&[("HOME", "/home/a")]),
)
.unwrap();
same(&d.data, "/home/a/.local/share/Orthic Labs/ViewRightDev");
same(&d.cache, "/home/a/.cache/Orthic Labs/ViewRightDev");
assert_eq!(AppId::new("X").namespace(false), "X");
assert_eq!(AppId::new("X").namespace(true), "XDev");
}
#[test]
fn missing_or_relative_base_is_an_error_never_the_cwd() {
let app = AppId::new("A");
let none = env_of(&[]);
for os in [Os::Windows, Os::MacOs, Os::Linux] {
assert!(matches!(
AppDirs::resolve_with(&app, &Options::new(os), &none),
Err(AppDirsError::NoBase { .. })
));
}
let rel = env_of(&[
("APPDATA", "rel"),
("LOCALAPPDATA", "rel"),
("USERPROFILE", "rel"),
]);
assert!(AppDirs::resolve_with(&app, &Options::new(Os::Windows), &rel).is_err());
let blank = env_of(&[("HOME", " ")]);
assert!(AppDirs::resolve_with(&app, &Options::new(Os::Linux), &blank).is_err());
}
#[test]
fn test_root_is_used_without_consulting_the_environment() {
let root = process_test_root();
let o = Options::new(Os::current()).test_root(Some(root.clone()));
let d = AppDirs::resolve_with(&AppId::new("ViewRight"), &o, &env_of(&[])).unwrap();
let r = root.join("ViewRight");
assert_eq!(d.data, r.join("data"));
assert_eq!(d.local, r.join("local"));
assert_eq!(d.cache, r.join("cache"));
assert_eq!(d.home, r.join("home"));
assert!(d.isolated);
for dir in [&d.data, &d.local, &d.cache, &d.home] {
assert!(dir.starts_with(std::env::temp_dir()));
}
let d = AppDirs::resolve_with(&AppId::new("ViewRight"), &o.clone().dev(true), &env_of(&[]))
.unwrap();
assert_eq!(d.data, root.join("ViewRightDev").join("data"));
let bad = Options::new(Os::Linux).test_root(Some(p("rel")));
assert!(AppDirs::resolve_with(&AppId::new("A"), &bad, &env_of(&[])).is_err());
}
#[test]
fn resolve_under_this_crates_own_tests_stays_in_the_temp_root() {
let d = AppDirs::resolve(AppId::new("ViewRight")).unwrap();
let root = process_test_root();
for dir in [&d.data, &d.local, &d.cache, &d.home] {
assert!(
dir.starts_with(&root),
"{} outside {}",
dir.display(),
root.display()
);
}
let t = AppDirs::for_test(AppId::new("ViewRight")).unwrap();
assert!(t.data.starts_with(&root));
}
#[test]
fn create_all_makes_the_folders() {
let d = AppDirs::for_test(AppId::new("AppDirsCreateAll")).unwrap();
d.create_all().unwrap();
assert!(d.data.is_dir() && d.local.is_dir() && d.cache.is_dir() && d.home.is_dir());
let _ = std::fs::remove_dir_all(process_test_root().join("AppDirsCreateAll"));
}
#[cfg(not(feature = "test-mode"))]
#[test]
fn overrides_are_ignored_without_test_mode() {
let env = env_of(&[
("HOME", "/home/a"),
("RIGHTKIT_QA_DATA_DIR", "/tmp/qa"),
("RIGHTKIT_QA_HOME", "/tmp/qahome"),
]);
let d = AppDirs::resolve_with(
&AppId::new("ViewRight"),
&Options::new(Os::Linux).qa_overrides(true),
&env,
)
.unwrap();
same(&d.data, "/home/a/.local/share/Orthic Labs/ViewRight");
assert!(!d.isolated);
}
#[cfg(feature = "test-mode")]
mod qa {
use super::*;
fn qa(os: Os) -> Options {
Options::new(os).qa_overrides(true)
}
#[test]
fn data_override_roots_every_folder_under_it() {
let env = env_of(&[("HOME", "/home/a"), ("RIGHTKIT_QA_DATA_DIR", "/tmp/qa")]);
let d = AppDirs::resolve_with(&AppId::new("ViewRight"), &qa(Os::Linux), &env).unwrap();
assert_eq!(d.data, p("/tmp/qa"));
same(&d.local, "/tmp/qa/local");
same(&d.cache, "/tmp/qa/cache");
same(&d.home, "/tmp/qa/home");
assert!(d.isolated);
}
#[test]
fn named_qa_home_wins_over_the_default_inside_the_data_dir() {
let env = env_of(&[
("RIGHTKIT_QA_DATA_DIR", "/tmp/qa"),
("RIGHTKIT_QA_HOME", "/tmp/qahome"),
]);
let d = AppDirs::resolve_with(&AppId::new("A"), &qa(Os::Linux), &env).unwrap();
assert_eq!(d.home, p("/tmp/qahome"));
}
#[test]
fn home_alone_keeps_real_data_but_isolates_home() {
let env = env_of(&[("HOME", "/home/a"), ("RIGHTKIT_QA_HOME", "/tmp/qahome")]);
let d = AppDirs::resolve_with(&AppId::new("A"), &qa(Os::Linux), &env).unwrap();
assert_eq!(d.home, p("/tmp/qahome"));
same(&d.data, "/home/a/.local/share/Orthic Labs/A");
assert!(d.isolated);
}
#[test]
fn legacy_variables_apply_after_the_shared_one() {
let app =
AppId::new("HeardRight").with_legacy_env(Some("HR_APP_DATA_DIR"), Some("HR_HOME"));
let env = env_of(&[("HR_APP_DATA_DIR", "/tmp/hr"), ("HR_HOME", "/tmp/hrhome")]);
let d = AppDirs::resolve_with(&app, &qa(Os::Linux), &env).unwrap();
assert_eq!((d.data, d.home), (p("/tmp/hr"), p("/tmp/hrhome")));
let env = env_of(&[
("HR_APP_DATA_DIR", "/tmp/hr"),
("RIGHTKIT_QA_DATA_DIR", "/tmp/shared"),
]);
let d = AppDirs::resolve_with(&app, &qa(Os::Linux), &env).unwrap();
assert_eq!(d.data, p("/tmp/shared"));
}
#[test]
fn relative_override_is_refused() {
for bad in ["rel/dir", "/tmp/../etc", ""] {
let env = env_of(&[("HOME", "/home/a"), ("RIGHTKIT_QA_DATA_DIR", bad)]);
let r = AppDirs::resolve_with(&AppId::new("A"), &qa(Os::Linux), &env);
if bad.is_empty() {
assert!(!r.unwrap().isolated);
} else {
assert!(matches!(r, Err(AppDirsError::NotAbsolute { .. })), "{bad}");
}
}
}
#[test]
fn override_at_or_inside_the_real_app_folder_is_refused() {
let app = AppId::new("ViewRight");
for dir in [
"/home/a/.local/share/Orthic Labs/ViewRight",
"/home/a/.local/share/Orthic Labs/ViewRight/qa",
"/home/a/.local/share/Orthic Labs/ViewRightDev",
"/home/a/.local/share/ViewRight",
"/home/a/.cache/Orthic Labs/ViewRight",
] {
let env = env_of(&[("HOME", "/home/a"), ("RIGHTKIT_QA_DATA_DIR", dir)]);
assert!(
matches!(
AppDirs::resolve_with(&app, &qa(Os::Linux), &env),
Err(AppDirsError::RealFolder { .. })
),
"{dir}"
);
}
let env = env_of(&[
("HOME", "/home/a"),
(
"RIGHTKIT_QA_DATA_DIR",
"/home/a/.local/share/Orthic Labs/ViewRightQA",
),
]);
assert!(AppDirs::resolve_with(&app, &qa(Os::Linux), &env).is_ok());
}
#[test]
fn windows_refusal_is_case_insensitive_and_covers_home() {
let mut pairs = WIN.to_vec();
pairs.push((
"RIGHTKIT_QA_DATA_DIR",
r"c:\users\A\appdata\roaming\orthic labs\viewright",
));
let r =
AppDirs::resolve_with(&AppId::new("ViewRight"), &qa(Os::Windows), &env_of(&pairs));
assert!(matches!(r, Err(AppDirsError::RealFolder { .. })));
let mut pairs = WIN.to_vec();
pairs.push(("RIGHTKIT_QA_HOME", r"C:\USERS\a"));
let r =
AppDirs::resolve_with(&AppId::new("ViewRight"), &qa(Os::Windows), &env_of(&pairs));
assert!(matches!(r, Err(AppDirsError::RealFolder { .. })));
let env = env_of(&[
("APPDATA", r"C:\qa\data"),
("LOCALAPPDATA", r"C:\qa\data"),
("USERPROFILE", r"C:\qa\home"),
("RIGHTKIT_QA_DATA_DIR", r"C:\qa\data"),
]);
let d =
AppDirs::resolve_with(&AppId::new("ViewRight"), &qa(Os::Windows), &env).unwrap();
assert_eq!(d.data, p(r"C:\qa\data"));
}
}
}