use std::path::{Path, PathBuf};
pub(crate) fn sidecar_path(exe: &Path) -> PathBuf {
exe.with_extension("old.exe")
}
pub fn clear_sidecar(exe: &Path) -> Result<PathBuf, String> {
clear_sidecar_with(exe, |path| std::fs::remove_file(path))
}
fn clear_sidecar_with(
exe: &Path,
remove: impl Fn(&Path) -> std::io::Result<()>,
) -> Result<PathBuf, String> {
let sidecar = sidecar_path(exe);
for leftover in leftover_sidecars(exe) {
let _ = remove(&leftover);
}
if std::fs::symlink_metadata(&sidecar).is_err() {
return Ok(sidecar);
}
if is_symlink(&sidecar) {
return Err(format!(
"refusing to replace symlinked rollback sidecar {}",
sidecar.display()
));
}
let parked = parked_path(exe);
std::fs::rename(&sidecar, &parked).map_err(|e| {
format!(
"previous lean-ctx binary {} is still locked ({e}). \
Run `lean-ctx stop`, close editors using lean-ctx, then retry.",
sidecar.display()
)
})?;
Ok(sidecar)
}
fn leftover_sidecars(exe: &Path) -> Vec<PathBuf> {
let (Some(dir), Some(stem)) = (exe.parent(), exe.file_stem().and_then(|s| s.to_str())) else {
return Vec::new();
};
let prefix = format!("{}.old", stem.to_ascii_lowercase());
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_file()))
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|name| {
name.to_ascii_lowercase()
.strip_prefix(&prefix)
.is_some_and(is_sidecar_suffix)
})
})
.collect();
found.sort();
found
}
fn is_sidecar_suffix(middle: &str) -> bool {
middle.is_empty()
|| middle.strip_prefix('-').is_some_and(|rest| {
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit() || c == '-')
})
}
fn parked_path(exe: &Path) -> PathBuf {
let stem = exe
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("lean-ctx");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
exe.with_file_name(format!("{stem}.old-{}-{nanos}.exe", std::process::id()))
}
fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn names(dir: &Path) -> Vec<String> {
let mut out: Vec<String> = fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
out.sort();
out
}
#[test]
fn missing_sidecar_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
fs::write(&exe, b"v2").unwrap();
assert_eq!(
clear_sidecar(&exe).unwrap(),
dir.path().join("lean-ctx.old.exe")
);
assert_eq!(names(dir.path()), ["lean-ctx.exe"]);
}
#[test]
fn released_sidecar_from_previous_swap_is_deleted() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
fs::write(&exe, b"v2").unwrap();
fs::write(dir.path().join("lean-ctx.old.exe"), b"v1").unwrap();
clear_sidecar(&exe).unwrap();
assert_eq!(names(dir.path()), ["lean-ctx.exe"]);
}
#[test]
fn held_sidecar_is_parked_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
let sidecar = dir.path().join("lean-ctx.old.exe");
fs::write(&exe, b"v2").unwrap();
fs::write(&sidecar, b"v1").unwrap();
let held = |_: &Path| Err(std::io::Error::other("in use"));
assert_eq!(clear_sidecar_with(&exe, held).unwrap(), sidecar);
assert!(!sidecar.exists());
let parked: Vec<_> = names(dir.path())
.into_iter()
.filter(|n| n.starts_with("lean-ctx.old-"))
.collect();
assert_eq!(parked.len(), 1, "{parked:?}");
assert_eq!(fs::read(dir.path().join(&parked[0])).unwrap(), b"v1");
assert_eq!(fs::read(&exe).unwrap(), b"v2");
}
#[test]
fn parked_sidecars_are_swept_once_released() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
fs::write(&exe, b"v3").unwrap();
fs::write(dir.path().join("lean-ctx.old-42-1700000000.exe"), b"v1").unwrap();
fs::write(dir.path().join("LEAN-CTX.OLD.EXE"), b"v2").unwrap();
clear_sidecar(&exe).unwrap();
assert_eq!(names(dir.path()), ["lean-ctx.exe"]);
}
#[test]
fn sweep_never_touches_unrelated_files() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
for name in [
"lean-ctx.exe",
"lean-ctx.older.exe",
"lean-ctx.old-notes.exe",
"lean-ctx.old.exe.bak",
"lean-ctx-update.bat",
"other.old.exe",
] {
fs::write(dir.path().join(name), b"x").unwrap();
}
clear_sidecar(&exe).unwrap();
assert_eq!(
names(dir.path()),
[
"lean-ctx-update.bat",
"lean-ctx.exe",
"lean-ctx.old-notes.exe",
"lean-ctx.old.exe.bak",
"lean-ctx.older.exe",
"other.old.exe",
]
);
}
#[cfg(unix)]
#[test]
fn symlinked_sidecar_is_refused() {
let dir = tempfile::tempdir().unwrap();
let exe = dir.path().join("lean-ctx.exe");
let target = dir.path().join("elsewhere");
fs::write(&exe, b"v2").unwrap();
fs::write(&target, b"keep").unwrap();
std::os::unix::fs::symlink(&target, dir.path().join("lean-ctx.old.exe")).unwrap();
let err = clear_sidecar(&exe).unwrap_err();
assert!(err.contains("symlinked"), "{err}");
assert_eq!(fs::read(&target).unwrap(), b"keep");
}
}