use path_clean::PathClean;
use std::{
borrow::Cow,
env, fs, io,
path::{Path, PathBuf},
sync::OnceLock,
};
const DATA_DIRECTORY_ENV_VAR: &str = "SLUMBER_DATA_DIRECTORY";
thread_local! {
static DATA_DIRECTORY_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
static LOG_FILE: OnceLock<PathBuf> = OnceLock::new();
#[cfg(any(test, feature = "test"))]
pub fn set_data_directory(path: PathBuf) {
DATA_DIRECTORY_OVERRIDE.with_borrow_mut(|dir| *dir = Some(path));
}
#[cfg(any(test, feature = "test"))]
pub fn reset_data_directory() {
DATA_DIRECTORY_OVERRIDE.with_borrow_mut(|dir| *dir = None);
}
pub fn config_directory() -> PathBuf {
debug_or(dirs::config_dir().unwrap().join("slumber"))
}
pub fn data_directory() -> PathBuf {
debug_or(dirs::data_dir().unwrap().join("slumber"))
}
pub fn log_file() -> PathBuf {
LOG_FILE
.get_or_init(|| {
#[cfg(debug_assertions)]
{
data_directory().join("slumber.log")
}
#[cfg(not(debug_assertions))]
{
use std::env;
use uuid::Uuid;
let directory = env::temp_dir();
let file_name = format!("slumber-{}.log", Uuid::new_v4());
directory.join(file_name)
}
})
.clone()
}
fn debug_or(path: PathBuf) -> PathBuf {
DATA_DIRECTORY_OVERRIDE
.with_borrow(Clone::clone)
.or_else(|| env::var(DATA_DIRECTORY_ENV_VAR).map(PathBuf::from).ok())
.unwrap_or_else(|| {
#[cfg(debug_assertions)]
{
let _ = path; get_repo_root().join("data/")
}
#[cfg(not(debug_assertions))]
{
path
}
})
}
pub fn create_parent(path: &Path) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"Cannot create directory for path {path}; it has no parent",
path = path.display()
),
)
})?;
fs::create_dir_all(parent)
}
#[cfg(any(debug_assertions, test))]
pub fn get_repo_root() -> &'static Path {
use std::{process::Command, sync::OnceLock};
static CACHE: OnceLock<PathBuf> = OnceLock::new();
CACHE.get_or_init(|| {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.unwrap();
let path = String::from_utf8(output.stdout).unwrap();
path.trim().into()
})
}
pub fn expand_home<'a>(path: impl Into<Cow<'a, Path>>) -> Cow<'a, Path> {
let path: Cow<_> = path.into();
match path.strip_prefix("~") {
Ok(rest) => {
let Some(home_dir) = dirs::home_dir() else {
return path;
};
home_dir.join(rest).into()
}
Err(_) => path,
}
}
pub fn normalize_path(base_dir: &Path, file: &Path) -> PathBuf {
base_dir.join(expand_home(file)).clean()
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::path::PathBuf;
#[rstest]
#[case::empty("", "")]
#[case::plain("test.txt", "test.txt")]
#[case::tilde_only("~", "{HOME}")]
#[case::tilde_dir("~/test.txt", "{HOME}/test.txt")]
#[case::tilde_double("~/~/test.txt", "{HOME}/~/test.txt")]
#[case::tilde_in_filename("~test.txt", "~test.txt")]
#[case::tilde_middle("text/~/test.txt", "text/~/test.txt")]
#[case::tilde_end("text/~", "text/~")]
fn test_expand_home(#[case] path: PathBuf, #[case] expected: &str) {
let expected = replace_home(expected);
assert_eq!(expand_home(&path).as_ref(), PathBuf::from(expected));
}
#[rstest]
#[case::relative("./file.yml", "/base/file.yml")]
#[case::absolute("./file.yml", "/base/file.yml")]
#[case::dots("../other/./file.yml", "/other/file.yml")]
#[case::home("./file.yml", "/base/file.yml")]
#[case::home("~/file.yml", "{HOME}/file.yml")]
fn test_normalize_path(#[case] file: &str, #[case] expected: &str) {
let expected = replace_home(expected);
assert_eq!(
normalize_path(Path::new("/base"), Path::new(file)),
Path::new(&expected)
);
}
fn replace_home(path: &str) -> String {
let home = dirs::home_dir().unwrap();
let home = home.to_str().unwrap();
assert!(!home.is_empty(), "Home dir is empty");
path.replace("{HOME}", home)
}
}