use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
pub fn data_dir() -> PathBuf {
#[cfg(test)]
return test_home().join("data");
#[cfg(not(test))]
resolve(std::env::var_os("XDG_DATA_HOME"), &[".local", "share"])
}
pub fn merged_record(name: &str) -> PathBuf {
#[cfg(test)]
{
let thread = std::thread::current();
let folder: String = thread
.name()
.unwrap_or("unnamed")
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
data_dir().join(folder).join(name)
}
#[cfg(not(test))]
data_dir().join(name)
}
pub fn config_dir() -> PathBuf {
#[cfg(test)]
return test_home().join("config");
#[cfg(not(test))]
resolve(std::env::var_os("XDG_CONFIG_HOME"), &[".config"])
}
#[cfg(test)]
fn test_home() -> PathBuf {
std::env::temp_dir().join(format!("pinch-points-test-{}", std::process::id()))
}
#[cfg(test)]
pub fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pinch-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch folder");
dir
}
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_alphanumeric() {
out.extend(ch.to_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let stem = if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
};
match is_reserved_on_windows(&stem) {
true => format!("{stem}_"),
false => stem,
}
}
fn is_reserved_on_windows(stem: &str) -> bool {
let stem = stem.to_ascii_lowercase();
if matches!(stem.as_str(), "con" | "prn" | "aux" | "nul") {
return true;
}
["com", "lpt"].iter().any(|port| {
stem.strip_prefix(port).is_some_and(|n| {
matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
|| matches!(n, "\u{b9}" | "\u{b2}" | "\u{b3}")
})
})
}
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 tmp = tmp_sibling(path);
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 tmp_sibling(path: &Path) -> PathBuf {
let mut tmp = path.as_os_str().to_owned();
tmp.push(format!(".{}.tmp", std::process::id()));
PathBuf::from(tmp)
}
fn spill(tmp: &Path, contents: &[u8]) -> std::io::Result<()> {
let mut file = std::fs::File::create(tmp)?;
file.write_all(contents)?;
file.sync_all()
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct ForeignLines(Vec<String>);
impl ForeignLines {
pub fn keep(&mut self, line: &str) {
let line = line.trim_end();
if !self.0.iter().any(|kept| kept == line) {
self.0.push(line.to_string());
}
}
pub fn extend(&mut self, other: ForeignLines) {
for line in other.0 {
self.keep(&line);
}
}
pub fn clear(&mut self) {
self.0.clear();
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
pub fn append_to(&self, text: &mut String) {
for line in &self.0 {
text.push_str(line);
text.push('\n');
}
}
}
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("***", 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);
}
#[test]
fn a_name_in_any_script_keeps_its_letters() {
use super::safe_stem;
assert_eq!(safe_stem("Моя карта", 40, "level"), "моя-карта");
assert_eq!(safe_stem("わたしのマップ", 40, "level"), "わたしのマップ");
assert_ne!(
safe_stem("Карта 2", 40, "level"),
safe_stem("Пляж 2", 40, "level")
);
assert_eq!(safe_stem("Café *3*", 40, "level"), "café-3");
assert!(safe_stem(&"я".repeat(200), 40, "level").len() <= 255);
}
#[test]
fn a_device_name_is_not_a_file_name() {
use super::safe_stem;
for name in ["Con", "PRN", "aux", "Nul", "COM1", "lpt9", "Com\u{b9}"] {
let stem = safe_stem(name, 40, "level");
assert!(stem.ends_with('_'), "{name} -> {stem}");
}
for name in ["Cone", "Com", "Com0", "Com10", "Lpt 1", "Console"] {
let stem = safe_stem(name, 40, "level");
assert!(!stem.ends_with('_'), "{name} -> {stem}");
}
}
use super::*;
#[test]
fn atomic_writes_replace_whole_files_and_leave_no_droppings() {
let dir = test_dir("paths-test");
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");
assert!(!tmp_sibling(&path).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 = test_dir("paths-fail");
let path = dir.join("save.txt");
write_atomic(&path, "the good one").expect("writes");
let tmp = tmp_sibling(&path);
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"));
}
}