#[cfg(not(feature = "tracing"))]
use log::debug;
#[cfg(feature = "tracing")]
use tracing::debug;
use crate::{Error, Release};
use std::{
path::{Path, PathBuf},
time::Duration,
};
#[cfg(test)]
use mockall::automock;
const MAX_RETRIES: u32 = 10;
const RETRY_DELAY: Duration = Duration::from_millis(500);
pub(crate) fn default() -> Box<dyn FileSystem + Send + Sync> {
Box::new(DefaultFileSystem {})
}
#[cfg_attr(test, automock)]
#[async_trait::async_trait]
pub trait FileSystem {
async fn delete_file(&self, path: &Path) -> Result<(), Error>;
async fn copy_file(&self, from: &Path, to: &Path) -> Result<(), Error>;
fn get_temp_app_path(&self, target_application: &Path, release: &Release) -> PathBuf;
}
#[derive(Debug)]
struct DefaultFileSystem {}
#[async_trait::async_trait]
impl FileSystem for DefaultFileSystem {
async fn delete_file(&self, path: &Path) -> Result<(), Error> {
let mut attempt = 0;
loop {
match tokio::fs::remove_file(path).await {
Ok(_) => return Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) if attempt >= MAX_RETRIES => {
return Err(human_errors::wrap_user(
e,
format!(
"Could not remove the file '{}' after {} retries.",
path.display(),
MAX_RETRIES
),
&[
"This usually means that the application is still running in another terminal. Please exit any running instances (including shells launched by it) before trying again.",
"Make sure that you have write permissions on the file, or try running the application with elevated permissions.",
],
));
}
Err(_) => {
debug!(
"Failed to remove '{}' (attempt {}); retrying.",
path.display(),
attempt + 1
);
attempt += 1;
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
async fn copy_file(&self, from: &Path, to: &Path) -> Result<(), Error> {
let mut attempt = 0;
loop {
match tokio::fs::copy(from, to).await {
Ok(_) => return Ok(()),
Err(e) if attempt >= MAX_RETRIES => {
return Err(human_errors::wrap_user(
e,
format!(
"Could not copy the new application file '{}' over the old application file '{}' after {} retries.",
from.display(),
to.display(),
MAX_RETRIES
),
&[
"This usually means that the application is still running in another terminal. Please exit any running instances (including shells launched by it) before trying again.",
"Make sure that you have write permissions on the application file, or try running the application with elevated permissions.",
],
));
}
Err(_) => {
debug!(
"Failed to copy '{}' -> '{}' (attempt {}); retrying.",
from.display(),
to.display(),
attempt + 1
);
attempt += 1;
tokio::time::sleep(RETRY_DELAY).await;
}
}
}
}
fn get_temp_app_path(&self, target_application: &Path, release: &Release) -> PathBuf {
let stem = target_application
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("update");
let ext = target_application
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}"))
.unwrap_or_else(|| {
if cfg!(windows) {
".exe".to_string()
} else {
String::new()
}
});
std::env::temp_dir().join(format!("{stem}-{}{ext}", release.id))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_release() -> Release {
Release {
id: "v1.0.0".to_string(),
changelog: String::new(),
version: "1.0.0".parse().unwrap(),
prerelease: false,
variant: None,
}
}
#[tokio::test]
async fn test_delete_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.txt");
tokio::fs::write(&path, "test").await.unwrap();
let fs = DefaultFileSystem {};
fs.delete_file(&path).await.unwrap();
assert!(!path.exists());
}
#[tokio::test]
async fn test_delete_missing_file_is_ok() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("does-not-exist.txt");
let fs = DefaultFileSystem {};
fs.delete_file(&path).await.unwrap();
}
#[tokio::test]
async fn test_copy_file() {
let temp = tempfile::tempdir().unwrap();
let from = temp.path().join("from.txt");
let to = temp.path().join("to.txt");
tokio::fs::write(&from, "test").await.unwrap();
let fs = DefaultFileSystem {};
fs.copy_file(&from, &to).await.unwrap();
assert!(to.exists());
assert_eq!(tokio::fs::read_to_string(&to).await.unwrap(), "test");
}
#[test]
fn test_get_temp_app_path() {
let fs = DefaultFileSystem {};
let release = test_release();
let with_ext = fs.get_temp_app_path(Path::new("/usr/bin/myapp.exe"), &release);
assert_eq!(with_ext.file_name().unwrap(), "myapp-v1.0.0.exe");
assert_eq!(with_ext.parent().unwrap(), std::env::temp_dir());
let without_ext = fs.get_temp_app_path(Path::new("/usr/bin/myapp"), &release);
#[cfg(windows)]
assert_eq!(without_ext.file_name().unwrap(), "myapp-v1.0.0.exe");
#[cfg(not(windows))]
assert_eq!(without_ext.file_name().unwrap(), "myapp-v1.0.0");
}
}