use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
pub fn data_dir() -> PathBuf {
resolve(std::env::var_os("XDG_DATA_HOME"), &[".local", "share"])
}
pub fn config_dir() -> PathBuf {
resolve(std::env::var_os("XDG_CONFIG_HOME"), &[".config"])
}
pub fn safe_stem(name: &str, keep: usize, fallback: &str) -> String {
let mut out = String::new();
for ch in name.chars().take(keep) {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
pub fn ensure_parent(path: &Path) {
if let Some(dir) = path.parent() {
let mut dirs = std::fs::DirBuilder::new();
dirs.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700);
let _ = dirs.create(dir);
}
}
pub fn write_atomic(path: &Path, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
ensure_parent(path);
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
let landed = spill(&tmp, contents.as_ref()).and_then(|()| std::fs::rename(&tmp, path));
if landed.is_err() {
let _ = std::fs::remove_file(&tmp);
}
landed
}
fn spill(tmp: &Path, contents: &[u8]) -> std::io::Result<()> {
let mut file = std::fs::File::create(tmp)?;
file.write_all(contents)?;
file.sync_all()
}
fn resolve(var: Option<OsString>, home_default: &[&str]) -> PathBuf {
var.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.unwrap_or_else(|| {
let mut dir = home();
for part in home_default {
dir.push(part);
}
dir
})
.join("pinch-points")
}
fn home() -> PathBuf {
["HOME", "USERPROFILE"]
.into_iter()
.find_map(|var| std::env::var_os(var).filter(|v| !v.is_empty()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(test)]
mod tests {
#[test]
fn a_name_becomes_a_file_name() {
use super::safe_stem;
assert_eq!(safe_stem("Gull Alley", 40, "level"), "gull-alley");
assert_eq!(safe_stem(" spaced out ", 40, "level"), "spaced-out");
assert_eq!(safe_stem("Crab/../etc", 40, "level"), "crab-etc");
assert_eq!(safe_stem("Café *3*", 40, "level"), "caf-3");
assert_eq!(safe_stem("***", 40, "level"), "level");
assert_eq!(safe_stem("", 40, "level"), "level");
assert!(safe_stem(&"x".repeat(200), 40, "level").len() <= 40);
assert!(safe_stem(&"x".repeat(200), 12, "round").len() <= 12);
}
use super::*;
#[test]
fn atomic_writes_replace_whole_files_and_leave_no_droppings() {
let dir = std::env::temp_dir().join(format!("pinch-paths-test-{}", std::process::id()));
let path = dir.join("nested").join("save.txt");
write_atomic(&path, "first").expect("writes");
write_atomic(&path, "second").expect("rewrites");
assert_eq!(std::fs::read_to_string(&path).expect("reads"), "second");
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
assert!(!Path::new(&tmp).exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path.parent().expect("has a parent"))
.expect("stats")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_failed_write_leaves_the_old_file_and_no_droppings() {
let dir = std::env::temp_dir().join(format!("pinch-paths-fail-{}", std::process::id()));
let path = dir.join("save.txt");
write_atomic(&path, "the good one").expect("writes");
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(&tmp);
std::fs::create_dir_all(&tmp).expect("a directory in the temp file's way");
assert!(write_atomic(&path, "the doomed one").is_err());
assert_eq!(
std::fs::read_to_string(&path).expect("reads"),
"the good one",
"the save that was already there is untouched"
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn absolute_xdg_wins_and_relative_is_ignored() {
let set = |v: &str| Some(OsString::from(v));
assert_eq!(
resolve(set("/somewhere/share"), &[".local", "share"]),
PathBuf::from("/somewhere/share/pinch-points")
);
let fallback = resolve(None, &[".config"]);
assert_eq!(resolve(set("relative/dir"), &[".config"]), fallback);
assert_eq!(resolve(set(""), &[".config"]), fallback);
assert!(fallback.ends_with(".config/pinch-points"));
}
}