thread_local! {
static TEST_HOME_OVERRIDE: std::cell::RefCell<Option<std::path::PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[must_use = "dropping the guard immediately restores the previous override"]
pub struct TestHomeGuard {
prev: Option<std::path::PathBuf>,
}
impl Drop for TestHomeGuard {
fn drop(&mut self) {
let prev = self.prev.take();
TEST_HOME_OVERRIDE.with(|o| *o.borrow_mut() = prev);
}
}
pub fn with_test_home_guard(home: &std::path::Path) -> TestHomeGuard {
let prev = TEST_HOME_OVERRIDE.with(|o| o.replace(Some(home.to_path_buf())));
TestHomeGuard { prev }
}
pub fn with_test_home<F, R>(home: &std::path::Path, f: F) -> R
where
F: FnOnce() -> R,
{
let _guard = with_test_home_guard(home);
f()
}
pub(crate) fn test_home_override() -> Option<std::path::PathBuf> {
TEST_HOME_OVERRIDE.with(|o| o.borrow().clone())
}
pub fn default_config_dir() -> std::path::PathBuf {
if let Some(home) = test_home_override() {
return home.join(".config").join("cfgd");
}
#[cfg(unix)]
{
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return std::path::PathBuf::from(xdg).join("cfgd");
}
expand_tilde(std::path::Path::new("~/.config/cfgd"))
}
#[cfg(windows)]
{
directories::BaseDirs::new()
.map(|b| b.config_dir().join("cfgd"))
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData\cfgd"))
}
}
pub fn default_runtime_dir() -> Option<std::path::PathBuf> {
#[cfg(target_os = "linux")]
{
if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
let xdg = std::path::PathBuf::from(xdg);
if !xdg.as_os_str().is_empty() {
return Some(xdg.join("cfgd"));
}
}
let home = home_dir_var()?;
Some(std::path::PathBuf::from(home).join(".cache").join("cfgd"))
}
#[cfg(target_os = "macos")]
{
let home = home_dir_var()?;
Some(
std::path::PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("cfgd"),
)
}
#[cfg(windows)]
{
if let Some(home) = test_home_override() {
return Some(home.join("AppData").join("Local").join("cfgd"));
}
directories::BaseDirs::new().map(|b| b.data_local_dir().join("cfgd"))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
{
let home = home_dir_var()?;
Some(std::path::PathBuf::from(home).join(".cache").join("cfgd"))
}
}
pub fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf {
let path_str = path.display().to_string();
let home = home_dir_var();
if let Some(home) = home {
if path_str == "~" {
return std::path::PathBuf::from(home);
}
if path_str.starts_with("~/") || path_str.starts_with("~\\") {
return std::path::PathBuf::from(path_str.replacen('~', &home, 1));
}
}
path.to_path_buf()
}
pub(crate) fn home_dir_var() -> Option<String> {
if let Some(home) = test_home_override() {
return Some(home.to_string_lossy().into_owned());
}
#[cfg(unix)]
{
std::env::var("HOME").ok()
}
#[cfg(windows)]
{
std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.ok()
}
}
pub fn resolve_relative_path(
path: &std::path::Path,
base: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, String> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
let joined = base.join(path);
validate_no_traversal(&joined)?;
Ok(joined)
}
}
pub fn validate_path_within(
path: &std::path::Path,
root: &std::path::Path,
) -> std::result::Result<std::path::PathBuf, std::io::Error> {
let canonical_root = root.canonicalize()?;
let canonical_path = path.canonicalize()?;
if !canonical_path.starts_with(&canonical_root) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"path {} escapes root {}",
canonical_path.posix(),
canonical_root.posix()
),
));
}
Ok(canonical_path)
}
pub fn validate_no_traversal(path: &std::path::Path) -> std::result::Result<(), String> {
for component in path.components() {
if let std::path::Component::ParentDir = component {
return Err(format!("path contains '..': {}", path.posix()));
}
}
Ok(())
}
pub fn copy_dir_recursive(
src: &std::path::Path,
dst: &std::path::Path,
) -> std::result::Result<(), std::io::Error> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let dst_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &dst_path)?;
} else {
std::fs::copy(entry.path(), &dst_path)?;
}
}
Ok(())
}
pub fn to_posix_string(path: impl AsRef<std::path::Path>) -> String {
path.as_ref().to_string_lossy().replace('\\', "/")
}
pub fn posixify_text(s: &str) -> std::borrow::Cow<'_, str> {
if s.contains('\\') {
std::borrow::Cow::Owned(s.replace('\\', "/"))
} else {
std::borrow::Cow::Borrowed(s)
}
}
pub fn to_file_url(path: impl AsRef<std::path::Path>) -> String {
let s = to_posix_string(path);
if s.starts_with('/') {
format!("file://{s}")
} else {
format!("file:///{s}")
}
}
pub fn normalize_line_endings(s: &str) -> std::borrow::Cow<'_, str> {
if s.contains("\r\n") {
std::borrow::Cow::Owned(s.replace("\r\n", "\n"))
} else {
std::borrow::Cow::Borrowed(s)
}
}
pub fn normalize_for_snapshot(captured: &str, paths: &[(&std::path::Path, &str)]) -> String {
let lf = normalize_line_endings(captured);
let posix = posixify_text(&lf);
let os = posixify_os_error_text(&posix);
let mut subs: Vec<(String, &str)> = paths
.iter()
.map(|(p, label)| (to_posix_string(p), *label))
.collect();
subs.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
let mut out = os.into_owned();
for (p, label) in subs {
if p.is_empty() {
continue;
}
out = out.replace(&p, label);
}
out
}
pub fn posixify_os_error_text(s: &str) -> std::borrow::Cow<'_, str> {
const STD_MARKER: &str = "(os error ";
const GIT_MARKER: &str = "; class=Os (";
if !s.contains(STD_MARKER) && !s.contains(GIT_MARKER) {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
let std_idx = rest.find(STD_MARKER);
let git_idx = rest.find(GIT_MARKER);
let (idx, marker, is_git) = match (std_idx, git_idx) {
(None, None) => {
out.push_str(rest);
break;
}
(Some(i), None) => (i, STD_MARKER, false),
(None, Some(i)) => (i, GIT_MARKER, true),
(Some(s_i), Some(g_i)) => {
if s_i <= g_i {
(s_i, STD_MARKER, false)
} else {
(g_i, GIT_MARKER, true)
}
}
};
let after_open = &rest[idx + marker.len()..];
let digits_end = after_open
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_open.len());
let is_well_formed = digits_end > 0 && after_open.as_bytes().get(digits_end) == Some(&b')');
if !is_well_formed {
let safe_end = idx + 1;
out.push_str(&rest[..safe_end]);
rest = &rest[safe_end..];
continue;
}
let prefix = &rest[..idx];
let cut = prefix.rfind(": ").map(|p| p + 2).unwrap_or(idx);
out.push_str(&prefix[..cut]);
out.push_str("<os error>");
if is_git {
out.push_str(GIT_MARKER);
out.push_str(&after_open[..digits_end + 1]);
}
rest = &after_open[digits_end + 1..];
}
std::borrow::Cow::Owned(out)
}
pub fn from_user_input(s: &str) -> std::path::PathBuf {
let folded = if s.contains('\\') {
s.replace('\\', "/")
} else {
s.to_string()
};
expand_tilde(std::path::Path::new(&folded))
}
pub trait PathDisplayExt {
fn display_posix(&self) -> String;
fn posix(&self) -> PathPosix<'_>;
}
impl<P: AsRef<std::path::Path>> PathDisplayExt for P {
fn display_posix(&self) -> String {
#[cfg(windows)]
{
to_posix_string(self.as_ref())
}
#[cfg(not(windows))]
{
self.as_ref().display().to_string()
}
}
fn posix(&self) -> PathPosix<'_> {
PathPosix(self.as_ref())
}
}
pub struct PathPosix<'a>(&'a std::path::Path);
impl std::fmt::Display for PathPosix<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[cfg(windows)]
{
let s = self.0.to_string_lossy();
for ch in s.chars() {
let mapped = if ch == '\\' { '/' } else { ch };
std::fmt::Write::write_char(f, mapped)?;
}
Ok(())
}
#[cfg(not(windows))]
{
std::fmt::Display::fmt(&self.0.display(), f)
}
}
}