use crate::path::{Path, PathBuf, PathExt};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Display;
use std::fs;
use std::fs::File;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(unix)]
use std::os::unix::prelude::*;
use std::sync::Mutex;
use std::time::Duration;
use bzip2::read::BzDecoder;
use color_eyre::eyre::{Context, Result};
use eyre::bail;
use filetime::{FileTime, set_file_times};
use flate2::read::GzDecoder;
use itertools::Itertools;
use std::sync::LazyLock as Lazy;
use tar::Archive;
use walkdir::WalkDir;
use zip::ZipArchive;
#[cfg(windows)]
use crate::config::Settings;
use crate::ui::progress_report::SingleReport;
use crate::{dirs, env};
pub fn open<P: AsRef<Path>>(path: P) -> Result<File> {
let path = path.as_ref();
trace!("open {}", display_path(path));
File::open(path).wrap_err_with(|| format!("failed open: {}", display_path(path)))
}
pub fn read<P: AsRef<Path>>(path: P) -> Result<Vec<u8>> {
let path = path.as_ref();
trace!("cat {}", display_path(path));
fs::read(path).wrap_err_with(|| format!("failed read: {}", display_path(path)))
}
pub fn size<P: AsRef<Path>>(path: P) -> Result<u64> {
let path = path.as_ref();
trace!("du -b {}", display_path(path));
path.metadata()
.map(|m| m.len())
.wrap_err_with(|| format!("failed size: {}", display_path(path)))
}
pub fn append<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
let path = path.as_ref();
trace!("append {}", display_path(path));
fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)
.and_then(|mut f| f.write_all(contents.as_ref()))
.wrap_err_with(|| format!("failed append: {}", display_path(path)))
}
pub fn remove_all<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
match path.metadata().map(|m| m.file_type()) {
Ok(x) if x.is_symlink() || x.is_file() => {
remove_file(path)?;
}
Ok(x) if x.is_dir() => {
trace!("rm -rf {}", display_path(path));
fs::remove_dir_all(path)
.wrap_err_with(|| format!("failed rm -rf: {}", display_path(path)))?;
}
_ => {}
};
Ok(())
}
pub fn remove_file_or_dir<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
match path.metadata().map(|m| m.file_type()) {
Ok(x) if x.is_dir() => {
remove_dir(path)?;
}
_ => {
remove_file(path)?;
}
};
Ok(())
}
pub fn remove_file<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
trace!("rm {}", display_path(path));
fs::remove_file(path).wrap_err_with(|| format!("failed rm: {}", display_path(path)))
}
pub async fn remove_file_async_if_exists<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
trace!("rm {}", display_path(path));
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).wrap_err_with(|| format!("failed rm: {}", display_path(path))),
}
}
pub fn remove_dir<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
(|| -> Result<()> {
if path.exists() && is_empty_dir(path)? {
trace!("rmdir {}", display_path(path));
fs::remove_dir(path)?;
}
Ok(())
})()
.wrap_err_with(|| format!("failed to remove_dir: {}", display_path(path)))
}
pub fn remove_dir_ignore<P: AsRef<Path>>(path: P, is_empty_ignore_files: Vec<&str>) -> Result<()> {
let path = path.as_ref();
(|| -> Result<()> {
if path.exists() && is_empty_dir_ignore(path, is_empty_ignore_files)? {
trace!("rm -rf {}", display_path(path));
remove_all_with_warning(path)?;
}
Ok(())
})()
.wrap_err_with(|| format!("failed to remove_dir: {}", display_path(path)))
}
pub fn remove_all_with_warning<P: AsRef<Path>>(path: P) -> Result<()> {
remove_all(&path).map_err(|e| {
warn!("failed to remove {}: {}", path.as_ref().display(), e);
e
})
}
pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
let from = from.as_ref();
let to = to.as_ref();
trace!("mv {} {}", from.display(), to.display());
fs::rename(from, to).wrap_err_with(|| {
format!(
"failed rename: {} -> {}",
display_path(from),
display_path(to)
)
})
}
pub fn move_file<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
let from = from.as_ref();
let to = to.as_ref();
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::CrossesDevices => {
if from.is_dir() {
create_dir_all(to)?;
copy_dir_all(from, to)?;
remove_all(from)?;
} else {
copy(from, to)?;
remove_file(from)?;
}
Ok(())
}
Err(err) => Err(err).wrap_err_with(|| {
format!(
"failed move: {} -> {}",
display_path(from),
display_path(to)
)
}),
}
}
pub fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
let from = from.as_ref();
let to = to.as_ref();
trace!("cp {} {}", from.display(), to.display());
fs::copy(from, to)
.wrap_err_with(|| {
format!(
"failed copy: {} -> {}",
display_path(from),
display_path(to)
)
})
.map(|_| ())
}
pub fn copy_dir_all<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
let from = from.as_ref();
let to = to.as_ref();
trace!("cp -r {} {}", from.display(), to.display());
recursive_ls(from)?.into_iter().try_for_each(|path| {
let relative = path.strip_prefix(from)?;
let dest = to.join(relative);
create_dir_all(dest.parent().unwrap())?;
copy(&path, &dest)?;
Ok(())
})
}
pub fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
let path = path.as_ref();
trace!("write {}", display_path(path));
fs::write(path, contents).wrap_err_with(|| format!("failed write: {}", display_path(path)))
}
pub async fn write_async<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
let path = path.as_ref();
trace!("write {}", display_path(path));
tokio::fs::write(path, contents)
.await
.wrap_err_with(|| format!("failed write: {}", display_path(path)))
}
pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
trace!("cat {}", path.display_user());
fs::read_to_string(path)
.wrap_err_with(|| format!("failed read_to_string: {}", path.display_user()))
}
pub async fn read_to_string_async<P: AsRef<Path>>(path: P) -> Result<String> {
let path = path.as_ref();
trace!("cat {}", path.display_user());
tokio::fs::read_to_string(path)
.await
.wrap_err_with(|| format!("failed read_to_string: {}", path.display_user()))
}
pub fn create(path: &Path) -> Result<File> {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
trace!("touch {}", display_path(path));
File::create(path).wrap_err_with(|| format!("failed create: {}", display_path(path)))
}
pub fn create_dir_all<P: AsRef<Path>>(path: P) -> Result<()> {
static LOCK: Lazy<Mutex<u8>> = Lazy::new(Default::default);
let _lock = LOCK.lock().unwrap();
let path = path.as_ref();
if !path.exists() {
trace!("mkdir -p {}", display_path(path));
if let Err(err) = fs::create_dir_all(path) {
if err.kind() != std::io::ErrorKind::AlreadyExists {
return Err(err)
.wrap_err_with(|| format!("failed create_dir_all: {}", display_path(path)));
}
}
}
Ok(())
}
pub fn display_path<P: AsRef<Path>>(path: P) -> String {
path.as_ref().display_user()
}
pub fn display_rel_path<P: AsRef<Path>>(path: P) -> String {
let path = path.as_ref();
match path.strip_prefix(dirs::CWD.as_ref().unwrap()) {
Ok(rel) => format!("./{}", rel.display()),
Err(_) => display_path(path),
}
}
pub fn replace_paths_in_string<S: Display>(input: S) -> String {
let home = env::HOME.to_string_lossy().to_string();
input.to_string().replace(&home, "~")
}
pub fn replace_path<P: AsRef<Path>>(path: P) -> PathBuf {
let path = path.as_ref();
match path.starts_with("~/") {
true => dirs::HOME.join(path.strip_prefix("~/").unwrap()),
false => path.to_path_buf(),
}
}
pub fn touch_file(file: &Path) -> Result<()> {
if !file.exists() {
create(file)?;
return Ok(());
}
trace!("touch_file {}", file.display());
let now = FileTime::now();
set_file_times(file, now, now)
.wrap_err_with(|| format!("failed to touch file: {}", display_path(file)))
}
pub fn touch_dir(dir: &Path) -> Result<()> {
trace!("touch {}", dir.display());
let now = FileTime::now();
set_file_times(dir, now, now)
.wrap_err_with(|| format!("failed to touch dir: {}", display_path(dir)))
}
#[cfg(unix)]
pub fn sync_dir<P: AsRef<Path>>(path: P) -> Result<()> {
let path = path.as_ref();
trace!("sync {}", display_path(path));
let dir = File::open(path)
.wrap_err_with(|| format!("failed to open dir for sync: {}", display_path(path)))?;
dir.sync_all()
.wrap_err_with(|| format!("failed to sync dir: {}", display_path(path)))
}
#[cfg(windows)]
pub fn sync_dir<P: AsRef<Path>>(_path: P) -> Result<()> {
Ok(())
}
pub fn modified_duration(path: &Path) -> Result<Duration> {
let metadata = path.metadata()?;
let modified = metadata.modified()?;
let duration = modified.elapsed().unwrap_or_default();
Ok(duration)
}
pub fn find_up<FN: AsRef<str>>(from: &Path, filenames: &[FN]) -> Option<PathBuf> {
let mut current = from.to_path_buf();
loop {
for filename in filenames {
let path = current.join(filename.as_ref());
if path.exists() {
return Some(path);
}
}
if !current.pop() {
return None;
}
}
}
pub fn dir_subdirs(dir: &Path) -> Result<BTreeSet<String>> {
let mut output = Default::default();
if !dir.exists() {
return Ok(output);
}
for entry in dir.read_dir()? {
let entry = entry?;
let ft = entry.file_type()?;
if ft.is_dir() || (ft.is_symlink() && entry.path().is_dir()) {
output.insert(entry.file_name().into_string().unwrap());
}
}
Ok(output)
}
pub fn ls(dir: &Path) -> Result<BTreeSet<PathBuf>> {
let mut output = Default::default();
if !dir.is_dir() {
return Ok(output);
}
for entry in dir.read_dir()? {
let entry = entry?;
output.insert(entry.path());
}
Ok(output)
}
pub fn recursive_ls(dir: &Path) -> Result<BTreeSet<PathBuf>> {
if !dir.is_dir() {
return Ok(Default::default());
}
Ok(WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_ok(|e| e.file_type().is_file())
.map_ok(|e| e.path().to_path_buf())
.try_collect()?)
}
#[cfg(unix)]
pub fn make_symlink(target: &Path, link: &Path) -> Result<(PathBuf, PathBuf)> {
trace!("ln -sf {} {}", target.display(), link.display());
if link.is_file() || link.is_symlink() {
fs::remove_file(link)?;
}
symlink(target, link)
.wrap_err_with(|| format!("failed to ln -sf {} {}", target.display(), link.display()))?;
Ok((target.to_path_buf(), link.to_path_buf()))
}
#[cfg(unix)]
pub fn make_symlink_or_copy(target: &Path, link: &Path) -> Result<()> {
make_symlink(target, link)?;
Ok(())
}
#[cfg(windows)]
pub fn make_symlink_or_copy(target: &Path, link: &Path) -> Result<()> {
copy(target, link)?;
Ok(())
}
#[cfg(windows)]
pub fn make_symlink(target: &Path, link: &Path) -> Result<(PathBuf, PathBuf)> {
if let Err(err) = junction::create(target, link) {
if err.kind() == std::io::ErrorKind::AlreadyExists {
let _ = fs::remove_file(link);
junction::create(target, link)
} else {
Err(err)
}
} else {
Ok(())
}
.wrap_err_with(|| format!("failed to ln -sf {} {}", target.display(), link.display()))?;
Ok((target.to_path_buf(), link.to_path_buf()))
}
#[cfg(windows)]
pub fn make_symlink_or_file(target: &Path, link: &Path) -> Result<()> {
trace!("ln -sf {} {}", target.display(), link.display());
if link.is_file() || link.is_symlink() {
fs::remove_file(link)?;
}
xx::file::write(link, target.to_string_lossy().to_string())?;
Ok(())
}
pub fn resolve_symlink(link: &Path) -> Result<Option<PathBuf>> {
if link.is_symlink() {
Ok(Some(fs::read_link(link)?))
} else if link.is_file() {
Ok(Some(fs::read_to_string(link)?.into()))
} else {
Ok(None)
}
}
#[cfg(unix)]
pub fn make_symlink_or_file(target: &Path, link: &Path) -> Result<()> {
make_symlink(target, link)?;
Ok(())
}
pub fn remove_symlinks_with_target_prefix(
symlink_dir: &Path,
target_prefix: &Path,
) -> Result<Vec<PathBuf>> {
if !symlink_dir.exists() {
return Ok(vec![]);
}
let mut removed = vec![];
for entry in symlink_dir.read_dir()? {
let entry = entry?;
let path = entry.path();
if path.is_symlink() {
let target = path.read_link()?;
if target.starts_with(target_prefix) {
fs::remove_file(&path)?;
removed.push(path);
}
}
}
Ok(removed)
}
#[cfg(unix)]
pub fn is_executable(path: &Path) -> bool {
if let Ok(metadata) = path.metadata() {
return metadata.permissions().mode() & 0o111 != 0;
}
false
}
#[cfg(windows)]
pub fn is_executable(path: &Path) -> bool {
if has_known_executable_extension(path) {
return true;
}
has_shebang(path)
}
#[cfg(windows)]
pub fn has_known_executable_extension(path: &Path) -> bool {
path.extension().map_or(
Settings::get()
.windows_executable_extensions
.contains(&String::new()),
|ext| {
if let Some(str_val) = ext.to_str() {
return Settings::get()
.windows_executable_extensions
.contains(&str_val.to_lowercase().to_string());
}
false
},
)
}
#[cfg(windows)]
pub fn has_shebang(path: &Path) -> bool {
std::fs::File::open(path)
.and_then(|mut f| {
use std::io::Read;
let mut buf = [0u8; 2];
f.read_exact(&mut buf)?;
Ok(buf == *b"#!")
})
.unwrap_or(false)
}
#[cfg(unix)]
pub fn make_executable<P: AsRef<Path>>(path: P) -> Result<()> {
trace!("chmod +x {}", display_path(&path));
let path = path.as_ref();
let mut perms = path.metadata()?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(path, perms)
.wrap_err_with(|| format!("failed to chmod +x: {}", display_path(path)))?;
Ok(())
}
#[cfg(windows)]
pub fn make_executable<P: AsRef<Path>>(_path: P) -> Result<()> {
Ok(())
}
#[cfg(unix)]
pub async fn make_executable_async<P: AsRef<Path>>(path: P) -> Result<()> {
trace!("chmod +x {}", display_path(&path));
let path = path.as_ref();
let mut perms = path.metadata()?.permissions();
perms.set_mode(perms.mode() | 0o111);
tokio::fs::set_permissions(path, perms)
.await
.wrap_err_with(|| format!("failed to chmod +x: {}", display_path(path)))
}
#[cfg(windows)]
pub async fn make_executable_async<P: AsRef<Path>>(_path: P) -> Result<()> {
Ok(())
}
pub fn all_dirs<P: AsRef<Path>>(
start_dir: P,
ceiling_dirs: &HashSet<PathBuf>,
) -> Result<Vec<PathBuf>> {
trace!(
"file::all_dirs Collecting all ancestors of {} until ceiling {:?}",
display_path(&start_dir),
ceiling_dirs
);
Ok(start_dir
.as_ref()
.ancestors()
.map_while(|p| {
if ceiling_dirs.contains(p) {
debug!(
"file::all_dirs Reached ceiling directory: {}",
display_path(p)
);
None
} else {
trace!(
"file::all_dirs Adding ancestor directory: {}",
display_path(p)
);
Some(p.to_path_buf())
}
})
.collect())
}
fn is_empty_dir(path: &Path) -> Result<bool> {
path.read_dir()
.map(|mut i| i.next().is_none())
.wrap_err_with(|| format!("failed to read_dir: {}", display_path(path)))
}
fn is_empty_dir_ignore(path: &Path, ignore_files: Vec<&str>) -> Result<bool> {
path.read_dir()
.map(|mut i| {
i.all(|entry| match entry {
Ok(entry) => ignore_files.iter().any(|ignore_file| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(ignore_file)
}),
Err(_) => false,
})
})
.wrap_err_with(|| format!("failed to read_dir: {}", display_path(path)))
}
pub struct FindUp {
current_dir: PathBuf,
current_dir_filenames: Vec<String>,
filenames: Vec<String>,
}
impl FindUp {
pub fn new(from: &Path, filenames: &[String]) -> Self {
let filenames: Vec<String> = filenames.iter().map(|s| s.to_string()).collect();
Self {
current_dir: from.to_path_buf(),
filenames: filenames.clone(),
current_dir_filenames: filenames,
}
}
}
impl Iterator for FindUp {
type Item = PathBuf;
fn next(&mut self) -> Option<Self::Item> {
while let Some(filename) = self.current_dir_filenames.pop() {
let path = self.current_dir.join(filename);
if path.is_file() {
return Some(path);
}
}
self.current_dir_filenames.clone_from(&self.filenames);
if cfg!(test) && self.current_dir == *dirs::HOME {
return None; }
if !self.current_dir.pop() {
return None;
}
self.next()
}
}
pub fn which<P: AsRef<Path>>(name: P) -> Option<PathBuf> {
static CACHE: Lazy<Mutex<HashMap<PathBuf, Option<PathBuf>>>> = Lazy::new(Default::default);
let name = name.as_ref();
if let Some(path) = CACHE.lock().unwrap().get(name) {
return path.clone();
}
let path = _which(name, &env::PATH);
CACHE
.lock()
.unwrap()
.insert(name.to_path_buf(), path.clone());
path
}
pub fn which_non_pristine<P: AsRef<Path>>(name: P) -> Option<PathBuf> {
_which(name, &env::PATH_NON_PRISTINE)
}
pub fn path_env_without_shims() -> std::ffi::OsString {
let shim_dir = &*dirs::SHIMS;
let filtered: Vec<_> = env::PATH_NON_PRISTINE
.iter()
.filter(|p| p.as_path() != *shim_dir)
.cloned()
.collect();
std::env::join_paths(filtered)
.unwrap_or_else(|_| std::env::var_os(&*env::PATH_KEY).unwrap_or_default())
}
pub fn strip_shims_from_path(path_val: &str) -> String {
let shim_dir = &*dirs::SHIMS;
let filtered = env::split_paths(path_val).filter(|p| p.as_path() != *shim_dir);
std::env::join_paths(filtered)
.unwrap_or_else(|_| std::ffi::OsString::from(path_val))
.to_string_lossy()
.into_owned()
}
pub fn which_no_shims<P: AsRef<Path>>(name: P) -> Option<PathBuf> {
let shim_dir = &*dirs::SHIMS;
let paths: Vec<PathBuf> = env::PATH_NON_PRISTINE
.iter()
.filter(|p| p.as_path() != *shim_dir)
.cloned()
.collect();
_which(name, &paths)
}
fn _which<P: AsRef<Path>>(name: P, paths: &[PathBuf]) -> Option<PathBuf> {
let name = name.as_ref();
paths.iter().find_map(|path| {
let bin = path.join(name);
if is_executable(&bin) { Some(bin) } else { None }
})
}
pub fn un_gz(input: &Path, dest: &Path) -> Result<()> {
debug!("gunzip {} > {}", input.display(), dest.display());
let f = File::open(input)?;
let mut dec = GzDecoder::new(f);
let mut output = File::create(dest)?;
std::io::copy(&mut dec, &mut output)
.wrap_err_with(|| format!("failed to un-gzip: {}", display_path(input)))?;
Ok(())
}
pub fn un_xz(input: &Path, dest: &Path) -> Result<()> {
debug!("xz -d {} -c > {}", input.display(), dest.display());
let f = File::open(input)?;
let mut dec = xz2::read::XzDecoder::new(f);
let mut output = File::create(dest)?;
std::io::copy(&mut dec, &mut output)
.wrap_err_with(|| format!("failed to un-xz: {}", display_path(input)))?;
Ok(())
}
pub fn un_zst(input: &Path, dest: &Path) -> Result<()> {
debug!("zstd -d {} -c > {}", input.display(), dest.display());
let f = File::open(input)?;
let mut dec = zstd::Decoder::new(f)?;
let mut output = File::create(dest)?;
std::io::copy(&mut dec, &mut output)
.wrap_err_with(|| format!("failed to un-zst: {}", display_path(input)))?;
Ok(())
}
pub fn un_bz2(input: &Path, dest: &Path) -> Result<()> {
debug!("bzip2 -d {} -c > {}", input.display(), dest.display());
let f = File::open(input)?;
let mut dec = BzDecoder::new(f);
let mut output = File::create(dest)?;
std::io::copy(&mut dec, &mut output)
.wrap_err_with(|| format!("failed to un-bz2: {}", display_path(input)))?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, strum::EnumString, strum::Display)]
pub enum TarFormat {
#[strum(serialize = "tar.gz", serialize = "tgz")]
TarGz,
#[strum(serialize = "gz")]
Gz,
#[strum(serialize = "tar.xz", serialize = "txz")]
TarXz,
#[strum(serialize = "xz")]
Xz,
#[strum(serialize = "tar.bz2", serialize = "tbz2")]
TarBz2,
#[strum(serialize = "bz2")]
Bz2,
#[strum(serialize = "tar.zst", serialize = "tzst")]
TarZst,
#[strum(serialize = "zst")]
Zst,
#[strum(serialize = "tar")]
Tar,
#[strum(serialize = "zip", serialize = "vsix")]
Zip,
#[strum(serialize = "7z")]
SevenZip,
#[strum(serialize = "raw")]
Raw,
}
impl TarFormat {
pub fn from_file_name(filename: &str) -> Self {
let filename = filename.to_lowercase();
if let Some(idx) = filename.rfind(".tar.") {
let ext = &filename[idx + 1..];
let fmt = Self::from_ext(ext);
if fmt != TarFormat::Raw {
return fmt;
}
}
if let Some(ext) = Path::new(&filename).extension().and_then(|s| s.to_str()) {
Self::from_ext(ext)
} else {
TarFormat::Raw
}
}
pub fn from_ext(ext: &str) -> Self {
ext.to_lowercase().parse().unwrap_or(TarFormat::Raw)
}
pub fn is_archive(&self) -> bool {
match self {
TarFormat::TarGz
| TarFormat::TarXz
| TarFormat::TarBz2
| TarFormat::TarZst
| TarFormat::Tar
| TarFormat::Zip
| TarFormat::SevenZip => true,
TarFormat::Gz | TarFormat::Xz | TarFormat::Bz2 | TarFormat::Zst | TarFormat::Raw => {
false
}
}
}
pub fn extension(&self) -> Option<&'static str> {
match self {
TarFormat::TarGz => Some("tar.gz"),
TarFormat::Gz => Some("gz"),
TarFormat::TarXz => Some("tar.xz"),
TarFormat::Xz => Some("xz"),
TarFormat::TarBz2 => Some("tar.bz2"),
TarFormat::Bz2 => Some("bz2"),
TarFormat::TarZst => Some("tar.zst"),
TarFormat::Zst => Some("zst"),
TarFormat::Tar => Some("tar"),
TarFormat::Zip => Some("zip"),
TarFormat::SevenZip => Some("7z"),
TarFormat::Raw => None,
}
}
}
pub struct TarOptions<'a> {
pub format: TarFormat,
pub strip_components: usize,
pub pr: Option<&'a dyn SingleReport>,
pub preserve_mtime: bool,
}
impl<'a> TarOptions<'a> {
pub fn new(format: TarFormat) -> Self {
Self {
format,
strip_components: 0,
pr: None,
preserve_mtime: true,
}
}
}
pub fn untar(archive: &Path, dest: &Path, opts: &TarOptions) -> Result<()> {
if opts.format == TarFormat::Zip {
return unzip(
archive,
dest,
&ZipOptions {
strip_components: opts.strip_components,
},
);
} else if opts.format == TarFormat::SevenZip {
#[cfg(windows)]
return un7z(
archive,
dest,
&SevenZipOptions {
strip_components: opts.strip_components,
},
);
}
debug!("tar -xf {} -C {}", archive.display(), dest.display());
if let Some(pr) = &opts.pr {
pr.set_message(format!(
"extract {}",
archive.file_name().unwrap().to_string_lossy()
));
}
let err = || {
let archive = display_path(archive);
let dest = display_path(dest);
format!("failed to extract tar: {archive} to {dest}")
};
let format = opts.format;
if !format.is_archive() && format != TarFormat::Raw {
let mut reader = open_tar(format, archive)?;
let out_path = if dest.is_dir() {
let name = archive
.file_stem()
.unwrap_or_else(|| archive.file_name().unwrap());
dest.join(name)
} else {
dest.to_path_buf()
};
if let Some(parent) = out_path.parent() {
create_dir_all(parent).wrap_err_with(err)?;
}
let mut out = File::create(&out_path).wrap_err_with(err)?;
std::io::copy(&mut reader, &mut out).wrap_err_with(err)?;
return Ok(());
}
let tar = open_tar(format, archive)?;
create_dir_all(dest).wrap_err_with(err)?;
let mut needs_system_tar = false;
for entry in Archive::new(tar).entries().wrap_err_with(err)? {
let mut entry = entry.wrap_err_with(err)?;
if entry.header().entry_type().is_gnu_sparse() {
debug!("Detected GNU sparse file, falling back to system tar");
needs_system_tar = true;
remove_all(dest)?;
create_dir_all(dest)?;
break;
}
entry.set_preserve_mtime(opts.preserve_mtime);
trace!("extracting {}", entry.path().wrap_err_with(err)?.display());
entry.unpack_in(dest).wrap_err_with(err)?;
}
if !needs_system_tar {
let sparse_dir = dest.join("GNUSparseFile.0");
if sparse_dir.exists() && sparse_dir.is_dir() {
debug!("Found GNUSparseFile.0 directory, using system tar");
needs_system_tar = true;
remove_all(dest)?;
create_dir_all(dest)?;
}
}
if needs_system_tar {
debug!("Using system tar for: {}", archive.display());
if !opts.preserve_mtime {
cmd!("tar", "-mxf", archive, "-C", dest)
.run()
.wrap_err_with(|| {
format!("Failed to extract {} using system tar", archive.display())
})?;
} else {
cmd!("tar", "-xf", archive, "-C", dest)
.run()
.wrap_err_with(|| {
format!("Failed to extract {} using system tar", archive.display())
})?;
}
}
strip_archive_path_components(dest, opts.strip_components).wrap_err_with(err)?;
Ok(())
}
fn open_tar(format: TarFormat, archive: &Path) -> Result<Box<dyn std::io::Read>> {
let f = File::open(archive)?;
Ok(match format {
TarFormat::TarGz | TarFormat::Gz | TarFormat::Raw => Box::new(GzDecoder::new(f)),
TarFormat::TarXz | TarFormat::Xz => Box::new(xz2::read::XzDecoder::new(f)),
TarFormat::TarBz2 | TarFormat::Bz2 => Box::new(BzDecoder::new(f)),
TarFormat::TarZst | TarFormat::Zst => Box::new(zstd::stream::read::Decoder::new(f)?),
TarFormat::Tar => Box::new(f),
TarFormat::Zip => bail!("zip format not supported"),
TarFormat::SevenZip => bail!("7z format not supported"),
})
}
fn strip_archive_path_components(dir: &Path, strip_depth: usize) -> Result<()> {
if strip_depth == 0 {
return Ok(());
}
if strip_depth > 1 {
bail!("strip-components > 1 is not supported");
}
let top_level_paths = ls(dir)?;
for path in top_level_paths {
if !path.symlink_metadata()?.is_dir() {
continue;
}
let temp_path = path.with_file_name(format!(
"{}_tmp_strip",
path.file_name().unwrap().to_string_lossy()
));
fs::rename(&path, &temp_path)?;
for entry in ls(&temp_path)? {
if let Some(file_name) = entry.file_name() {
let dest_path = dir.join(file_name);
fs::rename(entry, dest_path)?;
} else {
continue;
}
}
remove_dir(temp_path)?;
}
Ok(())
}
#[derive(Default)]
pub struct ZipOptions {
pub strip_components: usize,
}
pub fn unzip(archive: &Path, dest: &Path, opts: &ZipOptions) -> Result<()> {
debug!("unzip {} -d {}", archive.display(), dest.display());
ZipArchive::new(File::open(archive)?)
.wrap_err_with(|| format!("failed to open zip archive: {}", display_path(archive)))?
.extract(dest)
.wrap_err_with(|| format!("failed to extract zip archive: {}", display_path(archive)))?;
strip_archive_path_components(dest, opts.strip_components).wrap_err_with(|| {
format!(
"failed to strip path components from zip archive: {}",
display_path(archive)
)
})
}
pub fn un_dmg(archive: &Path, dest: &Path) -> Result<()> {
debug!(
"hdiutil attach -quiet -nobrowse -mountpoint {} {}",
dest.display(),
archive.display()
);
let tmp = tempfile::TempDir::new()?;
cmd!(
"hdiutil",
"attach",
"-quiet",
"-nobrowse",
"-mountpoint",
tmp.path(),
archive.to_path_buf()
)
.run()?;
copy_dir_all(tmp.path(), dest)?;
cmd!("hdiutil", "detach", tmp.path()).run()?;
Ok(())
}
pub fn un_pkg(archive: &Path, dest: &Path) -> Result<()> {
debug!(
"pkgutil --expand-full {} {}",
archive.display(),
dest.display()
);
cmd!("pkgutil", "--expand-full", archive, dest).run()?;
Ok(())
}
#[cfg(windows)]
#[derive(Default)]
pub struct SevenZipOptions {
pub strip_components: usize,
}
#[cfg(windows)]
pub fn un7z(archive: &Path, dest: &Path, opts: &SevenZipOptions) -> Result<()> {
sevenz_rust::decompress_file(archive, dest)
.wrap_err_with(|| format!("failed to extract 7z archive: {}", display_path(archive)))?;
strip_archive_path_components(dest, opts.strip_components).wrap_err_with(|| {
format!(
"failed to strip path components from 7z archive: {}",
display_path(archive)
)
})
}
pub fn split_file_name(path: &Path) -> (String, String) {
let file_name = path.file_name().unwrap().to_string_lossy();
let (file_name_base, ext) = file_name
.split_once('.')
.unwrap_or((file_name.as_ref(), ""));
(file_name_base.to_string(), ext.to_string())
}
pub fn same_file(a: &Path, b: &Path) -> bool {
desymlink_path(a) == desymlink_path(b)
}
pub fn desymlink_path(p: &Path) -> PathBuf {
if p.is_symlink()
&& let Ok(target) = fs::read_link(p)
{
return target
.canonicalize()
.unwrap_or_else(|_| target.to_path_buf());
}
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}
pub fn clone_dir(from: &PathBuf, to: &PathBuf) -> Result<()> {
if cfg!(macos) {
cmd!("cp", "-cR", from, to).run()?;
} else if cfg!(windows) {
cmd!("robocopy", from, to, "/MIR").run()?;
} else {
cmd!("cp", "--reflink=auto", "-r", from, to).run()?;
}
Ok(())
}
fn skip_curdir_components(path: &Path) -> impl Iterator<Item = std::path::Component<'_>> {
path.components()
.skip_while(|c| matches!(c, std::path::Component::CurDir))
}
pub fn inspect_tar_contents(archive: &Path, format: TarFormat) -> Result<Vec<(String, bool)>> {
let tar = open_tar(format, archive)?;
let mut archive = Archive::new(tar);
let mut top_level_components = std::collections::HashMap::new();
for entry in archive.entries()? {
let entry = entry?;
let path = entry.path()?;
let header = entry.header();
let mut components = skip_curdir_components(&path);
if let Some(first_component) = components.next() {
let name = first_component.as_os_str().to_string_lossy().to_string();
let is_directory = header.entry_type().is_dir() || components.next().is_some();
let existing = top_level_components.entry(name.clone()).or_insert(false);
*existing = *existing || is_directory;
}
}
Ok(top_level_components.into_iter().collect())
}
pub fn inspect_zip_contents(archive: &Path) -> Result<Vec<(String, bool)>> {
let f = File::open(archive)?;
let mut archive = ZipArchive::new(f)
.wrap_err_with(|| format!("failed to open zip archive: {}", display_path(archive)))?;
let mut top_level_components = std::collections::HashMap::new();
for i in 0..archive.len() {
let file = archive.by_index(i)?;
if let Some(path) = file.enclosed_name() {
let mut components = skip_curdir_components(&path);
if let Some(first_component) = components.next() {
let name = first_component.as_os_str().to_string_lossy().to_string();
let is_directory = file.is_dir() || components.next().is_some();
let existing = top_level_components.entry(name.clone()).or_insert(false);
*existing = *existing || is_directory;
}
}
}
Ok(top_level_components.into_iter().collect())
}
#[cfg(windows)]
pub fn inspect_7z_contents(archive: &Path) -> Result<Vec<(String, bool)>> {
let sevenz = sevenz_rust::SevenZReader::open(archive, sevenz_rust::Password::empty())?;
let mut top_level_components = std::collections::HashMap::new();
for file in &sevenz.archive().files {
let path = PathBuf::from(file.name());
let mut components = skip_curdir_components(&path);
if let Some(first_component) = components.next() {
let name = first_component.as_os_str().to_string_lossy().to_string();
let is_directory = file.is_directory() || components.next().is_some();
let existing = top_level_components.entry(name.clone()).or_insert(false);
*existing = *existing || is_directory;
}
}
Ok(top_level_components.into_iter().collect())
}
#[cfg(not(windows))]
pub fn inspect_7z_contents(_archive: &Path) -> Result<Vec<(String, bool)>> {
unimplemented!("7z format not supported on this platform")
}
pub fn should_strip_components(archive: &Path, format: TarFormat) -> Result<bool> {
let top_level_entries = match format {
TarFormat::Zip => inspect_zip_contents(archive)?,
TarFormat::SevenZip => inspect_7z_contents(archive)?,
_ => inspect_tar_contents(archive, format)?,
};
if top_level_entries.len() == 1 {
let (_, is_directory) = &top_level_entries[0];
Ok(*is_directory)
} else {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::config::Config;
use super::*;
#[tokio::test]
async fn test_find_up() {
let _config = Config::get().await.unwrap();
let path = &env::current_dir().unwrap();
let filenames = vec![".miserc", ".mise.toml", ".test-tool-versions"]
.into_iter()
.map(|s| s.to_string())
.collect_vec();
#[allow(clippy::needless_collect)]
let find_up = FindUp::new(path, &filenames).collect::<Vec<_>>();
let mut find_up = find_up.into_iter();
assert_eq!(
find_up.next(),
Some(dirs::HOME.join("cwd/.test-tool-versions"))
);
assert_eq!(find_up.next(), Some(dirs::HOME.join(".test-tool-versions")));
}
#[tokio::test]
async fn test_find_up_2() {
let _config = Config::get().await.unwrap();
let path = &dirs::HOME.join("fixtures");
let filenames = vec![".test-tool-versions"];
let result = find_up(path, &filenames);
assert_eq!(result, Some(dirs::HOME.join(".test-tool-versions")));
}
#[tokio::test]
async fn test_dir_subdirs() {
let _config = Config::get().await.unwrap();
let subdirs = dir_subdirs(&dirs::HOME).unwrap();
assert!(subdirs.contains("cwd"));
}
#[tokio::test]
#[cfg(unix)]
async fn test_display_path() {
let _config = Config::get().await.unwrap();
use std::ops::Deref;
let path = dirs::HOME.join("cwd");
assert_eq!(display_path(path), "~/cwd");
let path = Path::new("/tmp")
.join(dirs::HOME.deref().strip_prefix("/").unwrap())
.join("cwd");
assert_eq!(display_path(&path), path.display().to_string());
}
#[tokio::test]
async fn test_replace_path() {
let _config = Config::get().await.unwrap();
assert_eq!(replace_path(Path::new("~/cwd")), dirs::HOME.join("cwd"));
assert_eq!(replace_path(Path::new("/cwd")), Path::new("/cwd"));
}
#[test]
fn test_should_strip_components() {
let non_existent_path = Path::new("/non/existent/archive.tar.gz");
let result = should_strip_components(non_existent_path, TarFormat::TarGz);
assert!(result.is_err());
}
#[test]
fn test_inspect_tar_contents_logic() {
let mut components = std::collections::HashMap::new();
components.insert("mydir".to_string(), true);
let result: Vec<(String, bool)> = components.into_iter().collect();
assert_eq!(result.len(), 1);
let (name, is_directory) = &result[0];
assert_eq!(name, "mydir");
assert!(*is_directory);
let should_strip = result.len() == 1 && result[0].1;
assert!(should_strip);
}
#[test]
fn test_inspect_tar_contents_curdir_prefix() {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
use tempfile::NamedTempFile;
let temp_file = NamedTempFile::new().unwrap();
let gz = GzEncoder::new(temp_file.as_file(), Compression::default());
let mut builder = Builder::new(gz);
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header.clone(), "./dir1/file1", std::io::empty())
.unwrap();
builder
.append_data(&mut header.clone(), "./dir2/file2", std::io::empty())
.unwrap();
builder
.append_data(&mut header.clone(), "./standalone", std::io::empty())
.unwrap();
let gz = builder.into_inner().unwrap();
gz.finish().unwrap();
let result = inspect_tar_contents(temp_file.path(), TarFormat::TarGz).unwrap();
assert_eq!(
result.len(),
3,
"Expected 3 top-level entries, got: {:?}",
result
);
let names: std::collections::HashSet<_> = result.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains("dir1"), "Should contain dir1");
assert!(names.contains("dir2"), "Should contain dir2");
assert!(names.contains("standalone"), "Should contain standalone");
assert!(!names.contains("."), "Should NOT contain '.' (CurDir)");
for (name, is_dir) in &result {
if name == "dir1" || name == "dir2" {
assert!(*is_dir, "{} should be marked as directory", name);
} else if name == "standalone" {
assert!(!*is_dir, "standalone should NOT be marked as directory");
}
}
let should_strip = should_strip_components(temp_file.path(), TarFormat::TarGz).unwrap();
assert!(
!should_strip,
"Should NOT strip components for multi-entry archive"
);
}
#[test]
fn test_all_dirs_no_ceiling() {
let start_dir = Path::new("/a/b/c");
let ceiling_dirs = HashSet::new();
let result = all_dirs(start_dir, &ceiling_dirs).unwrap();
assert_eq!(result.len(), 4);
assert!(result.contains(&PathBuf::from("/a/b/c")));
assert!(result.contains(&PathBuf::from("/a/b")));
assert!(result.contains(&PathBuf::from("/a")));
assert!(result.contains(&PathBuf::from("/")));
}
#[test]
fn test_all_dirs_with_ceiling() {
let start_dir = Path::new("/a/b/c");
let mut ceiling_dirs = HashSet::new();
ceiling_dirs.insert(PathBuf::from("/a"));
let result = all_dirs(start_dir, &ceiling_dirs).unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains(&PathBuf::from("/a/b/c")));
assert!(result.contains(&PathBuf::from("/a/b")));
assert!(!result.contains(&PathBuf::from("/a")));
assert!(!result.contains(&PathBuf::from("/")));
}
#[test]
fn test_all_dirs_with_ceiling_at_start() {
let start_dir = Path::new("/a/b/c");
let mut ceiling_dirs = HashSet::new();
ceiling_dirs.insert(PathBuf::from("/a/b/c"));
let result = all_dirs(start_dir, &ceiling_dirs).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_all_dirs_with_multiple_ceilings() {
let start_dir = Path::new("/a/b/c/d/e");
let mut ceiling_dirs = HashSet::new();
ceiling_dirs.insert(PathBuf::from("/a/b"));
ceiling_dirs.insert(PathBuf::from("/a/b/c/d"));
let result = all_dirs(start_dir, &ceiling_dirs).unwrap();
assert_eq!(result.len(), 1);
assert!(result.contains(&PathBuf::from("/a/b/c/d/e")));
}
#[test]
fn test_all_dirs_with_relative_path() {
let start_dir = Path::new("a/b/c");
let ceiling_dirs = HashSet::new();
let result = all_dirs(start_dir, &ceiling_dirs).unwrap();
assert!(result.contains(&PathBuf::from("a/b/c")));
assert!(result.contains(&PathBuf::from("a/b")));
assert!(result.contains(&PathBuf::from("a")));
}
#[test]
fn test_tar_format_from_file_name() {
assert_eq!(TarFormat::from_file_name("foo.tar.gz"), TarFormat::TarGz);
assert_eq!(TarFormat::from_file_name("foo.tgz"), TarFormat::TarGz);
assert_eq!(TarFormat::from_file_name("foo.tar.xz"), TarFormat::TarXz);
assert_eq!(TarFormat::from_file_name("foo.txz"), TarFormat::TarXz);
assert_eq!(TarFormat::from_file_name("foo.tar.bz2"), TarFormat::TarBz2);
assert_eq!(TarFormat::from_file_name("foo.tbz2"), TarFormat::TarBz2);
assert_eq!(TarFormat::from_file_name("foo.tar.zst"), TarFormat::TarZst);
assert_eq!(TarFormat::from_file_name("foo.tzst"), TarFormat::TarZst);
assert_eq!(TarFormat::from_file_name("foo.tar"), TarFormat::Tar);
assert_eq!(TarFormat::from_file_name("foo.zip"), TarFormat::Zip);
assert_eq!(TarFormat::from_file_name("foo.vsix"), TarFormat::Zip);
assert_eq!(TarFormat::from_file_name("foo.7z"), TarFormat::SevenZip);
assert_eq!(TarFormat::from_file_name("foo.gz"), TarFormat::Gz);
assert_eq!(TarFormat::from_file_name("foo.xz"), TarFormat::Xz);
assert_eq!(TarFormat::from_file_name("foo.bz2"), TarFormat::Bz2);
assert_eq!(TarFormat::from_file_name("foo.zst"), TarFormat::Zst);
assert_eq!(TarFormat::from_file_name("foo"), TarFormat::Raw);
assert_eq!(TarFormat::from_file_name("foo.txt"), TarFormat::Raw);
}
#[test]
fn test_untar_single_file() {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let src_path = dir.path().join("test.gz");
let dest_path = dir.path().join("test-out");
let file = File::create(&src_path).unwrap();
let mut encoder = GzEncoder::new(file, Compression::default());
encoder.write_all(b"hello world").unwrap();
encoder.finish().unwrap();
untar(
&src_path,
&dest_path,
&TarOptions {
pr: None,
..TarOptions::new(TarFormat::Gz)
},
)
.unwrap();
assert!(dest_path.exists());
assert!(dest_path.is_file());
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "hello world");
}
#[test]
fn test_untar_single_file_to_dir() {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let src_path = dir.path().join("test_file.gz");
let dest_dir = dir.path().join("out_dir");
std::fs::create_dir(&dest_dir).unwrap();
let file = File::create(&src_path).unwrap();
let mut encoder = GzEncoder::new(file, Compression::default());
encoder.write_all(b"hello world").unwrap();
encoder.finish().unwrap();
untar(
&src_path,
&dest_dir,
&TarOptions {
pr: None,
..TarOptions::new(TarFormat::Gz)
},
)
.unwrap();
let expected_path = dest_dir.join("test_file");
assert!(expected_path.exists());
assert!(expected_path.is_file());
let content = std::fs::read_to_string(&expected_path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_remove_file_async_if_exists_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("file");
tokio::fs::write(&path, "content").await.unwrap();
remove_file_async_if_exists(&path).await.unwrap();
assert!(!path.exists());
}
#[tokio::test]
async fn test_remove_file_async_if_exists_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent");
remove_file_async_if_exists(&path).await.unwrap();
}
#[cfg(all(unix, target_os = "linux"))]
#[test]
fn test_move_file_falls_back_to_copy_across_filesystems() {
use std::{fs, os::unix::fs::MetadataExt};
use tempfile::tempdir_in;
let source_root = std::env::current_dir().unwrap();
let source_dir = tempdir_in(&source_root).unwrap();
let source_dev = source_dir.path().metadata().unwrap().dev();
let target_dir = tempdir_in("/tmp").unwrap();
if target_dir.path().metadata().unwrap().dev() == source_dev {
return;
}
let src = source_dir.path().join("bun");
let dst = target_dir.path().join("bun");
fs::write(&src, b"hello").unwrap();
move_file(&src, &dst).unwrap();
assert!(!src.exists());
assert_eq!(fs::read(&dst).unwrap(), b"hello");
}
#[cfg(all(unix, target_os = "linux"))]
#[test]
fn test_move_dir_falls_back_to_copy_across_filesystems() {
use std::{fs, os::unix::fs::MetadataExt};
use tempfile::tempdir_in;
let source_root = std::env::current_dir().unwrap();
let source_dir = tempdir_in(&source_root).unwrap();
let source_dev = source_dir.path().metadata().unwrap().dev();
let target_dir = tempdir_in("/tmp").unwrap();
if target_dir.path().metadata().unwrap().dev() == source_dev {
return;
}
let src = source_dir.path().join("bun-tree");
let dst = target_dir.path().join("bun-tree");
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("nested/bun"), b"hello").unwrap();
move_file(&src, &dst).unwrap();
assert!(!src.exists());
assert_eq!(fs::read(dst.join("nested/bun")).unwrap(), b"hello");
}
}