use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context;
use rustc_hash::FxHashMap;
#[cfg(test)]
use rustc_hash::FxHashSet;
use tracing::{debug, error, info, trace};
use crate::cli::reporter;
pub static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::current_dir()
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
.expect("The current directory must be exist")
});
static IN_PROCESS_LOCK_HELD_COUNTS: LazyLock<Mutex<FxHashMap<PathBuf, usize>>> =
LazyLock::new(Default::default);
#[cfg(test)]
static LOCK_WARNING_PATHS: LazyLock<Mutex<FxHashSet<PathBuf>>> = LazyLock::new(Default::default);
#[cfg(test)]
static FORCE_CROSS_PROCESS_LOCK_WARNING_FOR: LazyLock<Mutex<FxHashSet<PathBuf>>> =
LazyLock::new(Default::default);
#[derive(Debug)]
pub struct LockedFile {
file: fs_err::File,
path: PathBuf,
}
impl LockedFile {
fn lock_file_blocking(
file: fs_err::File,
resource: &str,
) -> Result<fs_err::File, std::io::Error> {
trace!(
resource,
path = %file.path().display(),
"Checking lock",
);
match file.try_lock() {
Ok(()) => {
debug!(resource, "Acquired lock");
Ok(file)
}
Err(err) => {
if !matches!(err, std::fs::TryLockError::WouldBlock) {
trace!(error = ?err, "Try lock error");
}
info!(
resource,
path = %file.path().display(),
"Waiting to acquire lock",
);
file.lock().map_err(|err| {
std::io::Error::other(format!(
"Could not acquire lock for `{resource}` at `{}`: {}",
file.path().display(),
err
))
})?;
trace!(resource, "Acquired lock");
Ok(file)
}
}
}
pub async fn acquire(
path: impl AsRef<Path>,
resource: impl Display,
) -> Result<Self, std::io::Error> {
let path = path.as_ref().to_path_buf();
let file = fs_err::File::create(&path)?;
let resource = resource.to_string();
let mut task =
tokio::task::spawn_blocking(move || Self::lock_file_blocking(file, &resource));
let warning_path = path.clone();
let file = tokio::select! {
result = &mut task => result??,
() = tokio::time::sleep(Duration::from_secs(1)) => {
let held_by_this_process = {
let held_by_this_process = IN_PROCESS_LOCK_HELD_COUNTS
.lock()
.unwrap()
.get(&warning_path)
.is_some_and(|count| *count > 0);
#[cfg(test)]
{
let forced_cross_process = FORCE_CROSS_PROCESS_LOCK_WARNING_FOR
.lock()
.unwrap()
.contains(&warning_path);
if forced_cross_process {
false
} else {
held_by_this_process
}
}
#[cfg(not(test))]
{
held_by_this_process
}
};
if !held_by_this_process {
reporter::suspend(move || {
#[cfg(test)]
{
LOCK_WARNING_PATHS.lock().unwrap().insert(warning_path);
}
#[cfg(not(test))]
{
crate::warn_user!(
"Waiting to acquire lock at `{}`. Another prek process may still be running",
warning_path.display()
);
}
});
}
task.await??
}
};
{
let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap();
*held.entry(path.clone()).or_insert(0) += 1;
}
Ok(Self { file, path })
}
}
impl Drop for LockedFile {
fn drop(&mut self) {
if let Err(err) = self.file.file().unlock() {
error!(
"Failed to unlock {}; program may be stuck: {}",
self.file.path().display(),
err
);
} else {
let mut held = IN_PROCESS_LOCK_HELD_COUNTS.lock().unwrap();
if let Some(count) = held.get_mut(&self.path) {
*count = count.saturating_sub(1);
if *count == 0 {
held.remove(&self.path);
}
}
trace!(path = %self.file.path().display(), "Released lock");
}
}
}
#[cfg(unix)]
pub(crate) fn normalize_path(path: PathBuf) -> PathBuf {
path
}
#[cfg(not(unix))]
pub(crate) fn normalize_path(path: PathBuf) -> PathBuf {
use std::ffi::OsString;
use std::path::is_separator;
let mut path = path.into_os_string().into_encoded_bytes();
for c in &mut path {
if *c == b'/' || !is_separator(char::from(*c)) {
continue;
}
*c = b'/';
}
match String::from_utf8(path) {
Ok(s) => PathBuf::from(s),
Err(e) => {
let path = e.into_bytes();
PathBuf::from(OsString::from(String::from_utf8_lossy(&path).as_ref()))
}
}
}
pub fn relative_to(
path: impl AsRef<Path>,
base: impl AsRef<Path>,
) -> Result<PathBuf, std::io::Error> {
let (stripped, common_prefix) = base
.as_ref()
.ancestors()
.find_map(|ancestor| {
dunce::simplified(path.as_ref())
.strip_prefix(dunce::simplified(ancestor))
.ok()
.map(|stripped| (stripped, ancestor))
})
.ok_or_else(|| {
std::io::Error::other(format!(
"Trivial strip failed: {} vs. {}",
path.as_ref().display(),
base.as_ref().display()
))
})?;
let levels_up = base.as_ref().components().count() - common_prefix.components().count();
let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
Ok(up.join(stripped))
}
pub(crate) async fn symlink_or_copy(source: &Path, target: &Path) -> anyhow::Result<()> {
match fs_err::tokio::symlink_metadata(target).await {
Ok(_) => {
fs_err::tokio::remove_file(target).await?;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
#[cfg(not(windows))]
{
match fs_err::tokio::symlink(source, target).await {
Ok(()) => {
trace!(
"Created symlink from {} to {}",
source.display(),
target.display()
);
return Ok(());
}
Err(e) => {
trace!(
"Failed to create symlink from {} to {}: {}",
source.display(),
target.display(),
e
);
}
}
}
#[cfg(windows)]
{
match fs_err::tokio::symlink_file(source, target).await {
Ok(()) => {
trace!(
"Created Windows symlink from {} to {}",
source.display(),
target.display()
);
return Ok(());
}
Err(e) => {
trace!(
"Failed to create Windows symlink from {} to {}: {}",
source.display(),
target.display(),
e
);
}
}
}
trace!(
"Falling back to copy from {} to {}",
source.display(),
target.display()
);
fs_err::tokio::copy(source, target).await.with_context(|| {
format!(
"Failed to copy file from {} to {}",
source.display(),
target.display(),
)
})?;
Ok(())
}
pub trait Simplified {
fn simplified(&self) -> &Path;
fn simplified_display(&self) -> impl Display;
fn user_display(&self) -> impl Display;
}
impl<T: AsRef<Path>> Simplified for T {
fn simplified(&self) -> &Path {
dunce::simplified(self.as_ref())
}
fn simplified_display(&self) -> impl Display {
dunce::simplified(self.as_ref()).display()
}
fn user_display(&self) -> impl Display {
let path = dunce::simplified(self.as_ref());
if CWD.ancestors().nth(1).is_none() {
return path.display();
}
let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
path.display()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
#[tokio::test]
#[cfg(unix)]
async fn symlink_or_copy_creates_symlink() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let source = tmp.path().join("source");
let target = tmp.path().join("target");
fs_err::write(&source, "source content")?;
super::symlink_or_copy(&source, &target).await?;
assert!(fs_err::symlink_metadata(&target)?.file_type().is_symlink());
assert_eq!(fs_err::read_link(target)?, source);
Ok(())
}
#[tokio::test]
async fn symlink_or_copy_replaces_existing_file() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let source = tmp.path().join("source");
let target = tmp.path().join("target");
fs_err::write(&source, "new content")?;
fs_err::write(&target, "old content")?;
super::symlink_or_copy(&source, &target).await?;
assert_eq!(fs_err::read_to_string(&target)?, "new content");
#[cfg(unix)]
assert!(fs_err::symlink_metadata(&target)?.file_type().is_symlink());
Ok(())
}
#[tokio::test]
async fn lock_warning_suppressed_for_in_process_contention() {
let tmp = tempfile::tempdir().expect("tempdir");
let lock_path = tmp.path().join(".lock");
let lock1 = super::LockedFile::acquire(&lock_path, "test-lock")
.await
.expect("acquire lock1");
let held_count = super::IN_PROCESS_LOCK_HELD_COUNTS
.lock()
.unwrap()
.get(&lock_path)
.copied();
assert_eq!(
held_count,
Some(1),
"expected held-count to be set after first acquire"
);
let lock_path2 = lock_path.clone();
let task =
tokio::spawn(async move { super::LockedFile::acquire(lock_path2, "test-lock").await });
tokio::time::sleep(Duration::from_millis(1100)).await;
let warning = super::LOCK_WARNING_PATHS
.lock()
.unwrap()
.contains(&lock_path);
assert!(
!warning,
"expected no warning for in-process contention, got: {warning:?}"
);
drop(lock1);
task.await.expect("join task").expect("acquire lock2");
}
#[tokio::test]
async fn lock_warning_emitted_when_forced_cross_process() {
let tmp = tempfile::tempdir().expect("tempdir");
let lock_path = tmp.path().join(".lock");
super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR
.lock()
.unwrap()
.insert(lock_path.clone());
let lock1 = super::LockedFile::acquire(&lock_path, "test-lock")
.await
.expect("acquire lock1");
let lock_path2 = lock_path.clone();
let task =
tokio::spawn(async move { super::LockedFile::acquire(lock_path2, "test-lock").await });
tokio::time::sleep(Duration::from_millis(1100)).await;
let warning = super::LOCK_WARNING_PATHS
.lock()
.unwrap()
.contains(&lock_path);
assert!(
warning,
"expected warning when forced cross-process mode is enabled"
);
super::FORCE_CROSS_PROCESS_LOCK_WARNING_FOR
.lock()
.unwrap()
.remove(&lock_path);
drop(lock1);
task.await.expect("join task").expect("acquire lock2");
}
}