use std::io::Write;
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Host {
Unix,
Windows,
}
pub const HOST: Host = if cfg!(windows) {
Host::Windows
} else {
Host::Unix
};
const APP_DIR_NAME: &str = if cfg!(debug_assertions) {
"kimun_debug"
} else {
"kimun"
};
const LOG_DIR_NAME: &str = "logs";
#[derive(Debug, thiserror::Error)]
pub enum SystemError {
#[error("path is not absolute: {path}")]
NotAbsolute {
path: String,
},
#[error("cannot determine the home directory")]
NoHome,
#[error("already exists: {path}")]
AlreadyExists {
path: String,
},
#[error("could not {action} {path}: held open by {holders}")]
Locked {
action: &'static str,
path: String,
holders: String,
#[source]
source: std::io::Error,
},
#[error("could not {action} {path}: {source}")]
Io {
action: &'static str,
path: String,
#[source]
source: std::io::Error,
},
}
impl SystemError {
fn io(action: &'static str, path: &Path, source: std::io::Error) -> Self {
Self::Io {
action,
path: path_to_string(path),
source,
}
}
fn locked_or_io(action: &'static str, path: &Path, source: std::io::Error) -> Self {
if is_locked_for(HOST, &source) {
Self::Locked {
action,
path: path_to_string(path),
holders: describe_holders(path),
source,
}
} else {
Self::io(action, path, source)
}
}
}
pub fn path_to_string<P: AsRef<Path>>(path: P) -> String {
path.as_ref()
.to_path_buf()
.into_os_string()
.into_string()
.unwrap_or_else(|os_string| os_string.to_string_lossy().into())
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SystemPath(PathBuf);
impl SystemPath {
pub fn try_absolute<P: AsRef<Path>>(path: P) -> Result<Self, SystemError> {
let path = path.as_ref();
if !path.is_absolute() {
return Err(SystemError::NotAbsolute {
path: path_to_string(path),
});
}
Ok(Self(normalize(path)))
}
pub fn resolve<P: AsRef<Path>>(path: P, base: &SystemPath) -> Self {
let path = path.as_ref();
let text = path.to_string_lossy();
let expanded = if text.starts_with("~/") || text == "~" {
match home() {
Ok(home) => home.0.join(text.strip_prefix("~/").unwrap_or("")),
Err(_) => path.to_path_buf(),
}
} else {
path.to_path_buf()
};
let absolute = if expanded.is_relative() {
base.0.join(expanded)
} else {
expanded
};
let absolute = normalize(&absolute);
Self(absolute.canonicalize().unwrap_or(absolute))
}
pub fn canonical<P: AsRef<Path>>(path: P) -> Result<Self, SystemError> {
let path = path.as_ref();
let canonical = path
.canonicalize()
.map_err(|e| SystemError::io("resolve", path, e))?;
Self::try_absolute(canonical)
}
pub fn join<S: AsRef<Path>>(&self, segment: S) -> Self {
Self(normalize(&self.0.join(segment)))
}
pub fn with_name_suffix(&self, suffix: &str) -> Self {
let mut name = self.0.as_os_str().to_os_string();
name.push(suffix);
Self(PathBuf::from(name))
}
pub fn parent(&self) -> Option<Self> {
self.0.parent().map(|p| Self(p.to_path_buf()))
}
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn into_path_buf(self) -> PathBuf {
self.0
}
pub fn exists(&self) -> bool {
self.0.exists()
}
pub fn is_dir(&self) -> bool {
self.0.is_dir()
}
}
impl AsRef<Path> for SystemPath {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl std::fmt::Display for SystemPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.display())
}
}
fn normalize(path: &Path) -> PathBuf {
let mut components = path.components();
let rooted = match components.next() {
Some(Component::RootDir) => true,
Some(Component::Prefix(_)) => components.next() == Some(Component::RootDir),
_ => false,
};
let mut out = PathBuf::new();
let mut cancellable = 0usize;
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => out.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if cancellable > 0 {
out.pop();
cancellable -= 1;
} else if !rooted {
out.push("..");
}
}
Component::Normal(name) => {
out.push(name);
cancellable += 1;
}
}
}
if out.as_os_str().is_empty() {
out.push(".");
}
out
}
pub fn home() -> Result<SystemPath, SystemError> {
home_from([std::env::var_os("HOME"), std::env::var_os("USERPROFILE")])
}
fn home_from<const N: usize>(
candidates: [Option<std::ffi::OsString>; N],
) -> Result<SystemPath, SystemError> {
candidates
.into_iter()
.flatten()
.filter(|raw| !raw.is_empty())
.find_map(|raw| SystemPath::try_absolute(PathBuf::from(raw)).ok())
.ok_or(SystemError::NoHome)
}
pub fn app_dir_under(home: &SystemPath, host: Host) -> SystemPath {
match host {
Host::Unix => home.join(".config").join(APP_DIR_NAME),
Host::Windows => home.join(APP_DIR_NAME),
}
}
pub fn app_dir() -> Result<SystemPath, SystemError> {
Ok(app_dir_under(&home()?, HOST))
}
pub fn browse_root() -> Result<SystemPath, SystemError> {
match home() {
Ok(home) => Ok(home),
Err(_) => {
let cwd = std::env::current_dir()
.map_err(|e| SystemError::io("resolve", Path::new("."), e))?;
SystemPath::try_absolute(cwd)
}
}
}
pub fn ensure_app_dir() -> Result<SystemPath, SystemError> {
let dir = app_dir()?;
ensure_dir(&dir)?;
Ok(dir)
}
pub fn log_dir() -> SystemPath {
match app_dir() {
Ok(dir) => dir.join(LOG_DIR_NAME),
Err(_) => log_dir_in_temp(
&std::env::temp_dir(),
&std::env::current_dir().unwrap_or_default(),
),
}
}
fn log_dir_in_temp(temp: &Path, cwd: &Path) -> SystemPath {
let candidate = temp.join(APP_DIR_NAME).join(LOG_DIR_NAME);
let absolute = if candidate.is_absolute() {
candidate
} else {
cwd.join(candidate)
};
SystemPath(normalize(&absolute))
}
pub fn ensure_dir(dir: &SystemPath) -> Result<(), SystemError> {
if !dir.is_dir() {
std::fs::create_dir_all(dir)
.map_err(|e| SystemError::io("create directory", dir.as_path(), e))?;
}
Ok(())
}
pub fn create_dir<P: AsRef<Path>>(path: P) -> Result<SystemPath, SystemError> {
let path = path.as_ref();
if !path.is_dir() {
std::fs::create_dir_all(path).map_err(|e| SystemError::io("create directory", path, e))?;
}
SystemPath::canonical(path)
}
pub fn is_locked_for(host: Host, err: &std::io::Error) -> bool {
match host {
Host::Unix => false,
Host::Windows => matches!(
err.raw_os_error(),
Some(32) | Some(33) ),
}
}
const LOCK_RETRY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
const LOCK_RETRY_MAX_BACKOFF: std::time::Duration = std::time::Duration::from_millis(500);
const LOCK_RETRY_LOG_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(500);
fn retry_while_locked<T>(
what: &str,
path: &Path,
mut op: impl FnMut() -> std::io::Result<T>,
) -> std::io::Result<T> {
let started = std::time::Instant::now();
let mut backoff = std::time::Duration::from_millis(1);
loop {
let result = op();
let Err(error) = &result else {
let waited = started.elapsed();
if waited >= LOCK_RETRY_LOG_THRESHOLD {
log::warn!(
"waited {waited:?} for another handle to release {} before {what} succeeded",
path.display()
);
}
return result;
};
if !is_locked_for(HOST, error) || started.elapsed() >= LOCK_RETRY_TIMEOUT {
if is_locked_for(HOST, error) {
log::warn!(
"gave up after {:?} waiting to {what} {}; held by: {}",
started.elapsed(),
path.display(),
describe_holders(path)
);
}
return result;
}
std::thread::sleep(backoff);
backoff = (backoff * 2).min(LOCK_RETRY_MAX_BACKOFF);
}
}
fn describe_holders(path: &Path) -> String {
let holders = holders_of(path);
if holders.is_empty() {
"unknown".to_string()
} else {
holders.join(", ")
}
}
#[cfg(not(windows))]
fn holders_of(_path: &Path) -> Vec<String> {
Vec::new()
}
#[cfg(windows)]
fn holders_of(path: &Path) -> Vec<String> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS};
use windows_sys::Win32::System::RestartManager::{
RmEndSession, RmGetList, RmRegisterResources, RmStartSession, CCH_RM_SESSION_KEY,
RM_PROCESS_INFO,
};
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut session: u32 = 0;
let mut key = [0u16; CCH_RM_SESSION_KEY as usize + 1];
if unsafe { RmStartSession(&mut session, 0, key.as_mut_ptr()) } != ERROR_SUCCESS {
return Vec::new();
}
let mut names = Vec::new();
let registered = unsafe {
RmRegisterResources(
session,
1,
&wide.as_ptr(),
0,
std::ptr::null(),
0,
std::ptr::null(),
)
};
if registered == ERROR_SUCCESS {
let mut needed: u32 = 0;
let mut count: u32 = 0;
let mut reason: u32 = 0;
let sized = unsafe {
RmGetList(
session,
&mut needed,
&mut count,
std::ptr::null_mut(),
&mut reason,
)
};
if (sized == ERROR_MORE_DATA || sized == ERROR_SUCCESS) && needed > 0 {
let mut infos: Vec<RM_PROCESS_INFO> =
vec![unsafe { std::mem::zeroed() }; needed as usize];
count = needed;
let got = unsafe {
RmGetList(
session,
&mut needed,
&mut count,
infos.as_mut_ptr(),
&mut reason,
)
};
if got == ERROR_SUCCESS {
for info in infos.iter().take(count as usize) {
let name = String::from_utf16_lossy(&info.strAppName);
let name = name.trim_end_matches('\0');
names.push(format!("{name} (pid {})", info.Process.dwProcessId));
}
}
}
}
unsafe { RmEndSession(session) };
names
}
pub fn move_file(from: &Path, to: &Path) -> Result<(), SystemError> {
match retry_while_locked("move", from, || std::fs::rename(from, to)) {
Ok(()) => Ok(()),
Err(e) if is_cross_device_for(HOST, &e) => copy_then_unlink(from, to),
Err(e) => Err(SystemError::locked_or_io("move", from, e)),
}
}
fn copy_then_unlink(from: &Path, to: &Path) -> Result<(), SystemError> {
let created_destination = !to.exists();
std::fs::copy(from, to).map_err(|e| SystemError::io("copy", from, e))?;
if let Err(e) = remove_file(from) {
if created_destination {
let _ = std::fs::remove_file(to);
}
return Err(e);
}
Ok(())
}
pub fn is_cross_device_for(host: Host, err: &std::io::Error) -> bool {
let expected = match host {
Host::Unix => 18, Host::Windows => 17, };
err.raw_os_error() == Some(expected)
}
static TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn temp_name_for(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(format!(
".{}.{}.tmp",
std::process::id(),
TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
PathBuf::from(name)
}
pub fn replace_atomically(path: &Path, contents: &[u8]) -> Result<(), SystemError> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
if !parent.is_dir() {
std::fs::create_dir_all(parent)
.map_err(|e| SystemError::io("create directory", parent, e))?;
}
}
let tmp = temp_name_for(path);
let write = (|| -> std::io::Result<()> {
let mut file = std::fs::File::create(&tmp)?;
file.write_all(contents)?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(SystemError::io("write", &tmp, e));
}
retry_while_locked("replace", path, || std::fs::rename(&tmp, path))
.map_err(|e| SystemError::locked_or_io("replace", path, e))?;
sync_dir(parent.unwrap_or(Path::new(".")));
Ok(())
}
fn sync_dir(dir: &Path) {
#[cfg(unix)]
match std::fs::File::open(dir).and_then(|d| d.sync_all()) {
Ok(()) => {}
Err(e) => log::debug!("could not flush directory {}: {e}", dir.display()),
}
#[cfg(not(unix))]
let _ = dir;
}
pub fn remove_file(path: &Path) -> Result<(), SystemError> {
match retry_while_locked("remove", path, || std::fs::remove_file(path)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SystemError::locked_or_io("remove", path, e)),
}
}
pub fn remove_empty_dir(path: &Path) -> Result<(), SystemError> {
match std::fs::remove_dir(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SystemError::io("remove directory", path, e)),
}
}
pub fn read_dir(dir: &SystemPath) -> Result<Vec<SystemPath>, SystemError> {
let entries =
std::fs::read_dir(dir).map_err(|e| SystemError::io("read directory", dir.as_path(), e))?;
let mut out = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| SystemError::io("read directory", dir.as_path(), e))?;
out.push(SystemPath(normalize(&entry.path())));
}
Ok(out)
}
pub fn make_executable(path: &Path) -> Result<(), SystemError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.map_err(|e| SystemError::io("read permissions of", path, e))?
.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms)
.map_err(|e| SystemError::io("set permissions on", path, e))?;
}
#[cfg(not(unix))]
let _ = path;
Ok(())
}
pub fn exe_name_for(host: Host, stem: &str) -> String {
match host {
Host::Unix => stem.to_string(),
Host::Windows => format!("{stem}.exe"),
}
}
#[cfg(test)]
pub(crate) fn sys<P: AsRef<Path>>(path: P) -> SystemPath {
SystemPath::try_absolute(&path).unwrap_or_else(|e| panic!("test path must be absolute: {e}"))
}
#[cfg(test)]
mod tests;