use std::path::{Path, PathBuf};
use crate::error::Result;
pub(crate) fn fnv1a_hex(data: &[u8]) -> String {
let mut hash: u64 = 14695981039346656037;
for &c in data {
hash ^= u64::from(c);
hash = hash.wrapping_mul(1099511628211);
}
format!("{:016x}", hash)
}
fn ends_with_case_insensitive(s: &str, suffix: &str) -> bool {
if suffix.len() > s.len() {
return false;
}
let offset = s.len() - suffix.len();
s.as_bytes()[offset..]
.iter()
.zip(suffix.as_bytes().iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
pub struct FileFunctions;
impl FileFunctions {
pub fn new() -> Self {
Self
}
pub fn get_unique_file_identifier(&self, filename: &str) -> Result<String> {
let abs = match std::path::absolute(filename) {
Ok(p) => p,
Err(_) => return Ok(String::new()),
};
if !abs.exists() {
return Ok(String::new());
}
let metadata = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return Ok(String::new()),
};
let mtime = match metadata.modified() {
Ok(t) => t,
Err(_) => return Ok(String::new()),
};
let count: i128 = mtime
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as i128)
.unwrap_or_else(|e| -(e.duration().as_nanos() as i128));
let mut hash_input = abs.to_string_lossy().into_owned();
hash_input.push_str(&count.to_string());
Ok(fnv1a_hex(hash_input.as_bytes()))
}
pub fn get_configuration_location(&self) -> Result<String> {
if let Ok(dir) = std::env::var("QUERCUS_CONFIG_DIR") {
if !dir.is_empty() {
let _ = std::fs::create_dir_all(&dir);
return Ok(dir);
}
}
if Self::is_portable() {
return Ok(Self::application_path());
}
#[cfg(target_os = "macos")]
let config_root = match std::env::var("HOME") {
Ok(h) if !h.is_empty() => PathBuf::from(h).join("Library").join("Application Support"),
_ => PathBuf::new(),
};
#[cfg(not(target_os = "macos"))]
let config_root = match std::env::var("XDG_CONFIG_HOME") {
Ok(x) if !x.is_empty() => PathBuf::from(x),
_ => match std::env::var("HOME") {
Ok(h) if !h.is_empty() => PathBuf::from(h).join(".config"),
_ => PathBuf::new(),
},
};
let config_root = if config_root.as_os_str().is_empty() {
std::env::temp_dir()
} else {
config_root
};
let config_dir = config_root.join("quercus");
let _ = std::fs::create_dir_all(&config_dir);
Ok(config_dir.to_string_lossy().into_owned())
}
pub fn get_application_path(&self) -> Result<String> {
Ok(Self::application_path())
}
fn is_portable() -> bool {
let app = Self::application_path();
!app.is_empty() && Path::new(&app).join("portable").exists()
}
fn application_path() -> String {
#[cfg(target_os = "macos")]
{
if let Ok(exe) = std::env::current_exe() {
let canonical = exe.canonicalize().unwrap_or(exe);
if let Some(parent) = canonical.parent() {
return parent.to_string_lossy().into_owned();
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
if let Ok(target) = std::fs::read_link("/proc/self/exe") {
if let Some(parent) = target.parent() {
return parent.to_string_lossy().into_owned();
}
}
}
std::env::current_dir()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn get_temp_file_path(&self) -> Result<String> {
let temp_path = std::env::temp_dir().join("quercus");
let _ = std::fs::create_dir_all(&temp_path);
Ok(temp_path.to_string_lossy().into_owned())
}
pub fn get_auto_recovery_root(&self) -> Result<String> {
let config = self.get_configuration_location()?;
Ok(PathBuf::from(config)
.join("autorecovery")
.to_string_lossy()
.into_owned())
}
pub fn can_copy_directory_without_overwriting(&self, source: &str, dest: &str) -> bool {
let Ok(entries) = std::fs::read_dir(source) else {
return true;
};
for entry in entries.flatten() {
let dest_equivalent = PathBuf::from(dest).join(entry.file_name());
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if !self.can_copy_directory_without_overwriting(
&entry.path().to_string_lossy(),
&dest_equivalent.to_string_lossy(),
) {
return false;
}
} else if dest_equivalent.exists() {
return false;
}
}
true
}
pub fn copy_directory(&self, source: &str, dest: &str, overwrite: bool) -> Result<()> {
if !Path::new(source).is_dir() {
eprintln!("Failed to copy directory, source {} didn't exist", source);
return Ok(());
}
if std::fs::create_dir_all(dest).is_err() {
eprintln!("Failed to create destination directory {}", dest);
return Ok(());
}
let Ok(entries) = std::fs::read_dir(source) else {
return Ok(());
};
for entry in entries.flatten() {
let entry_path = entry.path();
let dest_file_path = PathBuf::from(dest).join(entry.file_name());
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
self.copy_directory(
&entry_path.to_string_lossy(),
&dest_file_path.to_string_lossy(),
overwrite,
)?;
} else {
if overwrite {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&dest_file_path) {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() | 0o200 | 0o020 | 0o002);
let _ = std::fs::set_permissions(&dest_file_path, perms);
}
}
let _ = std::fs::remove_file(&dest_file_path);
}
if !overwrite && dest_file_path.exists() {
eprintln!(
"Failed to copy file {} to {}: File exists",
entry_path.to_string_lossy(),
dest_file_path.to_string_lossy()
);
continue;
}
if let Err(e) = std::fs::copy(&entry_path, &dest_file_path) {
eprintln!(
"Failed to copy file {} to {}: {}",
entry_path.to_string_lossy(),
dest_file_path.to_string_lossy(),
e
);
}
}
}
Ok(())
}
pub fn directory_is_valid(&self, dir: &str, try_to_create: bool) -> bool {
if Path::new(dir).is_dir() {
return true;
}
try_to_create && std::fs::create_dir_all(dir).is_ok()
}
pub fn ensure_filename_extension(&self, filename: &str, extension: &str) -> Result<String> {
let mut fn_str = filename.to_string();
if !fn_str.is_empty() && !extension.is_empty() {
let extension_with_dot = format!(".{}", extension);
if !ends_with_case_insensitive(&fn_str, &extension_with_dot) {
fn_str.push_str(&extension_with_dot);
}
}
Ok(fn_str)
}
pub fn read_file_as_string(&self, filename: &str) -> Result<String> {
match std::fs::read(filename) {
Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()),
Err(_) => Ok(String::new()),
}
}
pub fn get_safe_temporary_filename(&self, original: &str) -> Result<String> {
let mut counter: i32 = 0;
let original_path = PathBuf::from(original);
let dir = original_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let filename = original_path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let mut basename = filename.clone();
let mut complete_suffix = String::new();
if let Some(first_dot) = filename.find('.') {
basename = filename[..first_dot].to_string();
complete_suffix = filename[first_dot..].to_string();
}
let mut temp_abs_path;
loop {
temp_abs_path = dir.join(format!("{}.tmp{}{}", basename, counter, complete_suffix));
counter += 1;
if !temp_abs_path.exists() {
break;
}
}
Ok(temp_abs_path.to_string_lossy().into_owned())
}
pub fn rename_file_allow_overwrite(&self, from: &str, to: &str) -> bool {
if Path::new(to).exists() {
if std::fs::remove_file(to).is_err() {
eprintln!("Couldn't remove existing file {} for overwrite", to);
return false;
}
}
if std::fs::rename(from, to).is_err() {
eprintln!("Failed to rename file {} to {}", from, to);
return false;
}
true
}
pub fn get_formatted_executable_for_platform(&self, unformatted: &str) -> Result<String> {
#[cfg(target_os = "windows")]
{
Ok(format!("{}.exe", unformatted))
}
#[cfg(not(target_os = "windows"))]
{
Ok(unformatted.to_string())
}
}
}
#[allow(dead_code)]
pub(crate) fn config_location_path() -> Result<PathBuf> {
FileFunctions::new()
.get_configuration_location()
.map(PathBuf::from)
}
pub fn default_disk_cache_path() -> String {
if let Ok(custom) = crate::configstore::ConfigStore::instance().get(None, "DiskCachePath") {
if !custom.trim().is_empty() {
return custom;
}
}
Path::new(
&FileFunctions::new()
.get_configuration_location()
.unwrap_or_default(),
)
.join("mediacache")
.to_string_lossy()
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
fn config_lock() -> &'static Mutex<()> {
crate::test_support::env_lock()
}
fn unique_temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"quercus-filefunctions-test-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn fnv1a_known_vectors() {
assert_eq!(fnv1a_hex(b""), "cbf29ce484222325");
assert_eq!(fnv1a_hex(b"a"), "af63dc4c8601ec8c");
}
#[test]
fn ensure_extension_appends_and_is_case_insensitive() {
let f = FileFunctions::new();
assert_eq!(
f.ensure_filename_extension("foo", "ove").unwrap(),
"foo.ove"
);
assert_eq!(
f.ensure_filename_extension("foo.OVE", "ove").unwrap(),
"foo.OVE"
);
assert_eq!(
f.ensure_filename_extension("foo.ove", "Ove").unwrap(),
"foo.ove"
);
assert_eq!(
f.ensure_filename_extension("fooove", "ove").unwrap(),
"fooove.ove"
);
assert_eq!(f.ensure_filename_extension("", "ove").unwrap(), "");
assert_eq!(f.ensure_filename_extension("foo", "").unwrap(), "foo");
}
#[test]
fn formatted_executable_for_platform() {
let f = FileFunctions::new();
let v = f.get_formatted_executable_for_platform("myapp").unwrap();
#[cfg(target_os = "windows")]
assert_eq!(v, "myapp.exe");
#[cfg(not(target_os = "windows"))]
assert_eq!(v, "myapp");
}
#[test]
fn unique_identifier_existing_and_missing() {
let f = FileFunctions::new();
let dir = unique_temp_dir("id");
let file = dir.join("data.txt");
std::fs::write(&file, b"hello").unwrap();
let id = f
.get_unique_file_identifier(&file.to_string_lossy())
.unwrap();
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
let id2 = f
.get_unique_file_identifier(&file.to_string_lossy())
.unwrap();
assert_eq!(id, id2);
let missing = f
.get_unique_file_identifier(&dir.join("nope.txt").to_string_lossy())
.unwrap();
assert_eq!(missing, "");
}
#[test]
fn read_file_as_string_roundtrip() {
let f = FileFunctions::new();
let dir = unique_temp_dir("read");
let file = dir.join("x.txt");
std::fs::write(&file, b"hello world").unwrap();
assert_eq!(
f.read_file_as_string(&file.to_string_lossy()).unwrap(),
"hello world"
);
assert_eq!(
f.read_file_as_string(&dir.join("missing.txt").to_string_lossy())
.unwrap(),
""
);
}
#[test]
fn directory_is_valid_create_semantics() {
let f = FileFunctions::new();
let parent = unique_temp_dir("dirval");
let dir = parent.join("created");
assert!(!dir.exists());
assert!(f.directory_is_valid(&dir.to_string_lossy(), true));
assert!(dir.is_dir());
assert!(f.directory_is_valid(&dir.to_string_lossy(), false));
let missing = parent.join("never");
assert!(!missing.exists());
assert!(!f.directory_is_valid(&missing.to_string_lossy(), false));
}
#[test]
fn safe_temporary_filename_skips_existing() {
let f = FileFunctions::new();
let dir = unique_temp_dir("safetmp");
let original = dir.join("video.mp4");
std::fs::write(dir.join("video.tmp0.mp4"), b"x").unwrap();
let result = f
.get_safe_temporary_filename(&original.to_string_lossy())
.unwrap();
assert!(result.ends_with(".mp4"), "suffix preserved: {}", result);
assert!(
result.contains(".tmp1.mp4"),
"should skip existing .tmp0, got: {}",
result
);
assert!(!Path::new(&result).exists());
}
#[test]
fn rename_allow_overwrite() {
let f = FileFunctions::new();
let dir = unique_temp_dir("rename");
let from = dir.join("from.txt");
let to = dir.join("to.txt");
std::fs::write(&from, b"new").unwrap();
std::fs::write(&to, b"old").unwrap();
assert!(f.rename_file_allow_overwrite(&from.to_string_lossy(), &to.to_string_lossy()));
assert!(!from.exists());
assert_eq!(std::fs::read(&to).unwrap(), b"new");
let missing = dir.join("missing.txt");
assert!(!f.rename_file_allow_overwrite(
&missing.to_string_lossy(),
&dir.join("z.txt").to_string_lossy()
));
}
#[test]
fn copy_directory_recursive_and_overwrite_check() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp-src");
let nested = src.join("sub");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(src.join("a.txt"), b"a").unwrap();
std::fs::write(nested.join("b.txt"), b"b").unwrap();
let dest = unique_temp_dir("cp-dst");
assert!(f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
.unwrap();
assert!(dest.join("a.txt").exists());
assert!(dest.join("sub").join("b.txt").exists());
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"a");
f.copy_directory(
&dest.join("nope").to_string_lossy(),
&unique_temp_dir("cp-missing").to_string_lossy(),
false,
)
.unwrap();
}
#[test]
fn application_and_temp_paths() {
let f = FileFunctions::new();
let app = f.get_application_path().unwrap();
assert!(!app.is_empty());
assert!(Path::new(&app).is_dir());
let temp = f.get_temp_file_path().unwrap();
assert!(!temp.is_empty());
assert!(Path::new(&temp).is_dir());
}
#[test]
fn configuration_location_obeys_env_override() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let f = FileFunctions::new();
let dir = unique_temp_dir("config");
std::env::set_var("QUERCUS_CONFIG_DIR", &dir);
let loc = f.get_configuration_location().unwrap();
std::env::remove_var("QUERCUS_CONFIG_DIR");
assert_eq!(PathBuf::from(&loc), dir);
assert!(dir.is_dir());
}
#[test]
fn auto_recovery_root_derives_from_config() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let f = FileFunctions::new();
let dir = unique_temp_dir("config2");
std::env::set_var("QUERCUS_CONFIG_DIR", &dir);
let root = f.get_auto_recovery_root().unwrap();
std::env::remove_var("QUERCUS_CONFIG_DIR");
assert!(root.ends_with("autorecovery"));
assert!(PathBuf::from(&root).starts_with(&dir));
}
#[test]
fn config_location_path_helper() {
let _guard: MutexGuard<()> = config_lock().lock().unwrap();
let dir = unique_temp_dir("clp");
std::env::set_var("QUERCUS_CONFIG_DIR", &dir);
let p = config_location_path().unwrap();
std::env::remove_var("QUERCUS_CONFIG_DIR");
assert_eq!(p, dir);
}
#[test]
fn ends_with_case_insensitive_matrix() {
assert!(ends_with_case_insensitive("foo.OVE", ".ove"));
assert!(ends_with_case_insensitive("foo.ove", ".OVE"));
assert!(ends_with_case_insensitive("x", "x"));
assert!(ends_with_case_insensitive("x", ""));
assert!(!ends_with_case_insensitive("ove", ".ove"));
assert!(!ends_with_case_insensitive("", "a"));
assert!(ends_with_case_insensitive("a[", "["));
assert!(!ends_with_case_insensitive("a[", "{"));
assert!(ends_with_case_insensitive("vidéo.MP4", ".mp4"));
}
#[test]
fn ensure_extension_dotfile_multi_ext_and_unicode() {
let f = FileFunctions::new();
assert_eq!(
f.ensure_filename_extension(".hidden", "txt").unwrap(),
".hidden.txt"
);
assert_eq!(
f.ensure_filename_extension("archive.tar", "gz").unwrap(),
"archive.tar.gz"
);
assert_eq!(
f.ensure_filename_extension("archive.tar.gz", "gz").unwrap(),
"archive.tar.gz"
);
assert_eq!(
f.ensure_filename_extension("動画ファイル", "ove").unwrap(),
"動画ファイル.ove"
);
assert_eq!(f.ensure_filename_extension("foo.", "").unwrap(), "foo.");
}
#[test]
fn safe_temporary_filename_naming_variants() {
let f = FileFunctions::new();
let dir = unique_temp_dir("safetmp2");
let r0 = f
.get_safe_temporary_filename(&dir.join("clip.mov").to_string_lossy())
.unwrap();
assert!(r0.ends_with("clip.tmp0.mov"), "got: {}", r0);
let r1 = f
.get_safe_temporary_filename(&dir.join("a.tar.gz").to_string_lossy())
.unwrap();
assert!(r1.ends_with("a.tmp0.tar.gz"), "got: {}", r1);
let r2 = f
.get_safe_temporary_filename(&dir.join("README").to_string_lossy())
.unwrap();
assert!(r2.ends_with("README.tmp0"), "got: {}", r2);
let r3 = f
.get_safe_temporary_filename(&dir.join(".hidden").to_string_lossy())
.unwrap();
assert!(r3.ends_with(".tmp0.hidden"), "got: {}", r3);
let r4 = f.get_safe_temporary_filename("video.mp4").unwrap();
assert_eq!(r4, "video.tmp0.mp4");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unique_identifier_differs_by_path() {
let f = FileFunctions::new();
let dir = unique_temp_dir("id2");
let a = dir.join("a.txt");
let b = dir.join("b.txt");
std::fs::write(&a, b"same").unwrap();
std::fs::write(&b, b"same").unwrap();
let ida = f.get_unique_file_identifier(&a.to_string_lossy()).unwrap();
let idb = f.get_unique_file_identifier(&b.to_string_lossy()).unwrap();
assert_ne!(ida, idb);
let idd = f
.get_unique_file_identifier(&dir.to_string_lossy())
.unwrap();
assert_eq!(idd.len(), 16);
let bogus = f
.get_unique_file_identifier("definitely-not-here.xyz")
.unwrap();
assert_eq!(bogus, "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn read_file_as_string_binary_and_lossy() {
let f = FileFunctions::new();
let dir = unique_temp_dir("read2");
let bad = dir.join("bad.bin");
std::fs::write(&bad, b"a\xff\xfeb").unwrap();
let s = f.read_file_as_string(&bad.to_string_lossy()).unwrap();
assert_eq!(s, "a\u{FFFD}\u{FFFD}b");
let empty = dir.join("empty.txt");
std::fs::write(&empty, b"").unwrap();
assert_eq!(f.read_file_as_string(&empty.to_string_lossy()).unwrap(), "");
assert_eq!(f.read_file_as_string(&dir.to_string_lossy()).unwrap(), "");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn directory_is_valid_blocked_by_file() {
let f = FileFunctions::new();
let parent = unique_temp_dir("dirval2");
let blocker = parent.join("blocked");
std::fs::write(&blocker, b"x").unwrap();
assert!(!f.directory_is_valid(&blocker.to_string_lossy(), true));
let nested = blocker.join("child");
assert!(!f.directory_is_valid(&nested.to_string_lossy(), true));
let deep = parent.join("x").join("y").join("z");
assert!(f.directory_is_valid(&deep.to_string_lossy(), true));
assert!(deep.is_dir());
let _ = std::fs::remove_dir_all(&parent);
}
#[test]
fn copy_directory_no_overwrite_preserves_existing() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp2-src");
std::fs::write(src.join("a.txt"), b"new").unwrap();
let dest = unique_temp_dir("cp2-dst");
std::fs::write(dest.join("a.txt"), b"old").unwrap();
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
assert!(f.can_copy_directory_without_overwriting(
&src.join("nope").to_string_lossy(),
&dest.to_string_lossy()
));
std::fs::write(src.join("b.txt"), b"b").unwrap();
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), false)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"old");
assert_eq!(std::fs::read(dest.join("b.txt")).unwrap(), b"b");
f.copy_directory(&src.to_string_lossy(), &dest.to_string_lossy(), true)
.unwrap();
assert_eq!(std::fs::read(dest.join("a.txt")).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn can_copy_detects_nested_conflict() {
let f = FileFunctions::new();
let src = unique_temp_dir("cp3-src");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub").join("deep.txt"), b"x").unwrap();
let dest = unique_temp_dir("cp3-dst");
std::fs::create_dir_all(dest.join("sub")).unwrap();
std::fs::write(dest.join("sub").join("deep.txt"), b"y").unwrap();
assert!(!f.can_copy_directory_without_overwriting(
&src.to_string_lossy(),
&dest.to_string_lossy()
));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dest);
}
#[test]
fn rename_to_fresh_destination() {
let f = FileFunctions::new();
let dir = unique_temp_dir("rename2");
let from = dir.join("only.txt");
let to = dir.join("fresh.txt");
std::fs::write(&from, b"data").unwrap();
assert!(f.rename_file_allow_overwrite(&from.to_string_lossy(), &to.to_string_lossy()));
assert_eq!(std::fs::read(&to).unwrap(), b"data");
let from2 = dir.join("only2.txt");
std::fs::write(&from2, b"z").unwrap();
let todir = dir.join("destdir");
std::fs::create_dir(&todir).unwrap();
assert!(!f.rename_file_allow_overwrite(&from2.to_string_lossy(), &todir.to_string_lossy()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn temp_file_path_under_system_temp() {
let f = FileFunctions::new();
let temp = f.get_temp_file_path().unwrap();
assert_eq!(PathBuf::from(&temp), std::env::temp_dir().join("quercus"));
assert!(Path::new(&temp).is_dir());
}
#[test]
fn unique_temp_dirs_are_actually_unique() {
let a = unique_temp_dir("uniq");
let b = unique_temp_dir("uniq");
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
}