// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! File/directory helpers, mirroring `src/common/src/filefunctions.h` //! and `include/common/filefunctions.h`. Mirrors the filefunctions C++ //! namespace as a handle-bearing family for C ABI shape uniformity; the //! underlying operations are stateless. //! //! Two-stage string getters here return their result via `buf`/`buf_size` //! (see `crate::ffi`); the domain functions below return the owned string. use std::path::{Path, PathBuf}; use crate::error::Result; /// FNV-1a 64-bit hash of `data`, returned as lowercase hex (`%016llx`). /// /// Mirrors the anonymous `fnv1a_hex` helper in `src/common/src/filefunctions.cpp`. /// `pub(crate)` since `displayicc` reuses it to name ICC cache files. 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) } /// Case-insensitive check whether `s` ends with `suffix`. /// /// Mirrors `ends_with_case_insensitive` in `filefunctions.cpp`; only `A-Z` /// are folded to lower case (like the C++ `a >= 'A' && a <= 'Z'` test). 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.to_ascii_lowercase() == b.to_ascii_lowercase()) } /// The filefunctions family (stateless; the handle only exists for C ABI /// uniformity). pub struct FileFunctions; impl FileFunctions { /// Creates the filefunctions family object. pub fn new() -> Self { Self } /// Deterministic identifier string for a file (empty if it does not /// exist). pub fn get_unique_file_identifier(&self, filename: &str) -> Result { // `std::path::absolute` is the std analog of `fs::absolute`: it // yields an absolute path without resolving symlinks or normalizing. 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()), }; // C++ appends `to_string(mtime.time_since_epoch().count())`; on // macOS `file_time_type` has nanosecond precision, so `.as_nanos()` // is the closest equivalent (used only as a cache-key salt). // CPP-PARITY: `time_since_epoch().count()` on a pre-epoch timestamp // is negative; Rust's `duration_since` returns an error which we fold // into the negated magnitude. 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())) } /// Configuration directory. pub fn get_configuration_location(&self) -> Result { // Tests and tooling can redirect the configuration (and, since most // locations derive from it, the cache/data) root. if let Ok(dir) = std::env::var("OAK_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()); } // CPP-PARITY: the C++ `#ifdef __APPLE__` picks `~/Library/Application // Support`; the non-Apple branch prefers `$XDG_CONFIG_HOME` then // `~/.config`. The empty-root fallback is the temp directory. #[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("oak"); let _ = std::fs::create_dir_all(&config_dir); Ok(config_dir.to_string_lossy().into_owned()) } /// Application path. pub fn get_application_path(&self) -> Result { Ok(Self::application_path()) } /// Whether the application is running in portable mode (a `portable` file /// sits next to the application executable). Mirrors /// `FileFunctions::is_portable`. fn is_portable() -> bool { let app = Self::application_path(); !app.is_empty() && Path::new(&app).join("portable").exists() } /// Application path, without the `Result` wrapper (used internally). fn application_path() -> String { #[cfg(target_os = "macos")] { // CPP-PARITY: C++ uses `_NSGetExecutablePath` + `weakly_canonical`; // `std::env::current_exe` + `canonicalize` is the portable // equivalent and yields the same parent directory. 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(); } } } // Fallback: current working directory std::env::current_dir() .map(|c| c.to_string_lossy().into_owned()) .unwrap_or_default() } /// Temporary file path. pub fn get_temp_file_path(&self) -> Result { // CPP-PARITY: `std::env::temp_dir()` never fails the way // `fs::temp_directory_path(ec)` can, so the `"." / "oak-temp"` // fallback is unreachable here and omitted. let temp_path = std::env::temp_dir().join("oak"); let _ = std::fs::create_dir_all(&temp_path); Ok(temp_path.to_string_lossy().into_owned()) } /// Auto-recovery root directory. pub fn get_auto_recovery_root(&self) -> Result { let config = self.get_configuration_location()?; Ok(PathBuf::from(config) .join("autorecovery") .to_string_lossy() .into_owned()) } /// Whether `source` can be copied to `dest` without overwriting. pub fn can_copy_directory_without_overwriting(&self, source: &str, dest: &str) -> bool { // CPP-PARITY: a failed `directory_iterator` (e.g. `source` missing) // yields an empty iteration, so the function returns `true`. 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()); // `entry.is_directory(ec)` failing (ec set) falls through to the // "else exists" branch in C++; `unwrap_or(false)` mirrors that. 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 } /// Recursively copy a directory, optionally overwriting. 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(()); } // CPP-PARITY: a failed `directory_iterator` on `source` aborts the // copy silently (C++ prints nothing); skipping matches that. 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) { // Copy dir self.copy_directory( &entry_path.to_string_lossy(), &dest_file_path.to_string_lossy(), overwrite, )?; } else { // Copy file if overwrite { // C++ adds owner/group/others write permission then // removes the destination (so read-only files can be // replaced). CPP-PARITY: gated to `unix` because Rust's // `PermissionsExt` is Unix-only; on Windows the write // permissions already allow removal. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = std::fs::metadata(&dest_file_path) { let mut perms = meta.permissions(); // owner_write|group_write|others_write 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); } // CPP-PARITY: C++ `fs::copy_file` with `copy_options::none` // FAILS when the destination exists, but Rust's // `std::fs::copy` would silently overwrite it — so the // no-overwrite case must be guarded explicitly. 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(()) } /// Whether a directory exists, optionally creating it. pub fn directory_is_valid(&self, dir: &str, try_to_create: bool) -> bool { // Return whether the directory exists, or whether it could be created // if it doesn't if Path::new(dir).is_dir() { return true; } try_to_create && std::fs::create_dir_all(dir).is_ok() } /// Filename with a (dotless) extension ensured. pub fn ensure_filename_extension(&self, filename: &str, extension: &str) -> Result { let mut fn_str = filename.to_string(); // No-op if either input is empty 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) } /// The entire file read into a string (empty if it cannot be read). pub fn read_file_as_string(&self, filename: &str) -> Result { // CPP-PARITY: the C++ returns the raw byte buffer as a `std::string`; // Rust `String` must be valid UTF-8, so invalid bytes are replaced // (lossy) rather than preserved. Project/XML files are UTF-8, so this // matches in practice. match std::fs::read(filename) { Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).into_owned()), Err(_) => Ok(String::new()), } } /// A non-existing temporary variant of `original`. pub fn get_safe_temporary_filename(&self, original: &str) -> Result { 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(); // Split off the complete suffix (everything from the first dot), like // QFileInfo::completeSuffix() 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()) } /// Rename `from` to `to`, deleting `to` first if it exists. pub fn rename_file_allow_overwrite(&self, from: &str, to: &str) -> bool { if Path::new(to).exists() { // CPP-PARITY: C++ `fs::remove` handles both files and dirs; Rust // has no single "remove" that does both, and the use case here is // file renames, so `remove_file` is used. if std::fs::remove_file(to).is_err() { eprintln!("Couldn't remove existing file {} for overwrite", to); return false; } } // By this point, we can assume `to` either never existed or has now // been deleted if std::fs::rename(from, to).is_err() { eprintln!("Failed to rename file {} to {}", from, to); return false; } true } /// Append the platform executable suffix (".exe" on Windows). pub fn get_formatted_executable_for_platform(&self, unformatted: &str) -> Result { #[cfg(target_os = "windows")] { Ok(format!("{}.exe", unformatted)) } #[cfg(not(target_os = "windows"))] { Ok(unformatted.to_string()) } } } /// Location query helper reserved for the two-stage C getters; returns a /// path suitable for `std::fs`. pub(crate) fn config_location_path() -> Result { FileFunctions::new() .get_configuration_location() .map(PathBuf::from) } #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, MutexGuard}; // The configuration-location tests mutate the process-wide `OAK_CONFIG_DIR` // env var, which is also read by `get_auto_recovery_root` and // `config_location_path`. Rust runs tests in parallel, so serialize those // env-dependent tests with a shared lock. We use the crate-wide test lock so // `configstore` tests (which also mutate `OAK_CONFIG_DIR`) serialize on the // SAME mutex. 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!( "oak-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() { // Offset basis as lowercase hex, and the canonical FNV-1a-64 of "a". 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" ); // Already present (case-insensitive): untouched. assert_eq!( f.ensure_filename_extension("foo.OVE", "ove").unwrap(), "foo.OVE" ); assert_eq!( f.ensure_filename_extension("foo.ove", "Ove").unwrap(), "foo.ove" ); // The "." in the suffix must actually be present. assert_eq!( f.ensure_filename_extension("fooove", "ove").unwrap(), "fooove.ove" ); // Empty inputs are no-ops. 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())); // Deterministic for the same file. 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" ); // Missing file -> empty string. 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()); // Now it exists, so even without create it is valid. 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"); // Block the first candidate so the counter must advance. 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"); // Renaming a missing source fails. 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"); // Nothing in dest yet: safe to copy without overwriting. 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"); // Copying again would overwrite a.txt. assert!(!f.can_copy_directory_without_overwriting( &src.to_string_lossy(), &dest.to_string_lossy() )); // With overwrite it succeeds and refreshes content. 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"); // A missing source is a silent no-op (returns Ok). 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("OAK_CONFIG_DIR", &dir); let loc = f.get_configuration_location().unwrap(); std::env::remove_var("OAK_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("OAK_CONFIG_DIR", &dir); let root = f.get_auto_recovery_root().unwrap(); std::env::remove_var("OAK_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("OAK_CONFIG_DIR", &dir); let p = config_location_path().unwrap(); std::env::remove_var("OAK_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", "")); // Suffix longer than the string never matches. assert!(!ends_with_case_insensitive("ove", ".ove")); assert!(!ends_with_case_insensitive("", "a")); // Only ASCII A-Z fold (like the C++ range test); other bytes compare // exactly. 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(); // Dotfiles and multi-extension names just get the suffix appended. 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" ); // Unicode names pass through unchanged apart from the suffix. assert_eq!( f.ensure_filename_extension("動画ファイル", "ove").unwrap(), "動画ファイル.ove" ); // A bare "." suffix (empty extension) is a no-op per the C++. 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"); // Counter 0 is used when nothing blocks it. let r0 = f .get_safe_temporary_filename(&dir.join("clip.mov").to_string_lossy()) .unwrap(); assert!(r0.ends_with("clip.tmp0.mov"), "got: {}", r0); // Complete suffix = everything from the FIRST dot (QFileInfo // completeSuffix semantics, `filefunctions.cpp:318-326`). 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); // No extension at all. let r2 = f .get_safe_temporary_filename(&dir.join("README").to_string_lossy()) .unwrap(); assert!(r2.ends_with("README.tmp0"), "got: {}", r2); // Dotfile: basename is empty, complete suffix is the whole name. let r3 = f .get_safe_temporary_filename(&dir.join(".hidden").to_string_lossy()) .unwrap(); assert!(r3.ends_with(".tmp0.hidden"), "got: {}", r3); // No parent directory: the candidate is relative ("name.tmp0.ext"). 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(); // The absolute path is part of the hash input // (`filefunctions.cpp:100-103`), so identical content in different // paths yields different identifiers. 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); // A directory also gets an identifier (C++ only checks exists()). let idd = f .get_unique_file_identifier(&dir.to_string_lossy()) .unwrap(); assert_eq!(idd.len(), 16); // Empty filename -> absolute() of "" is the CWD which exists; but a // definitely-bogus relative name yields "". 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"); // Invalid UTF-8 is replaced (lossy), not an error. 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"); // Empty file reads as empty, indistinguishable from a missing file. let empty = dir.join("empty.txt"); std::fs::write(&empty, b"").unwrap(); assert_eq!(f.read_file_as_string(&empty.to_string_lossy()).unwrap(), ""); // A directory cannot be read as a file -> empty. 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"); // A regular file blocks directory creation at the same path. let blocker = parent.join("blocked"); std::fs::write(&blocker, b"x").unwrap(); assert!(!f.directory_is_valid(&blocker.to_string_lossy(), true)); // ...and so does a file in the MIDDLE of the path to create. let nested = blocker.join("child"); assert!(!f.directory_is_valid(&nested.to_string_lossy(), true)); // Nested creation works when nothing blocks it. 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(); // would-overwrite detection fires on the direct conflict... assert!(!f.can_copy_directory_without_overwriting( &src.to_string_lossy(), &dest.to_string_lossy() )); // ...and a missing source trivially reports "safe" (empty iteration, // `filefunctions.cpp:202`). assert!(f.can_copy_directory_without_overwriting( &src.join("nope").to_string_lossy(), &dest.to_string_lossy() )); // overwrite=false: the copy of the conflicting file fails (logged) // and the destination keeps its old content; other files still copy. 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"); // overwrite=true replaces the conflicting file. 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(); // The conflict is two levels down: recursion must find it. 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"); // Destination existing as a DIRECTORY cannot be removed by // remove_file, so the rename fails (documented CPP-PARITY // divergence from C++ fs::remove, which would remove an empty dir). 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(); // `/oak`, created on demand (`filefunctions.cpp:184-196`). assert_eq!(PathBuf::from(&temp), std::env::temp_dir().join("oak")); 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); } } /// The default disk cache directory (C++ `DiskManager:: /// get_default_disk_cache_path`): `/mediacache`. /// /// The `DiskCachePath` config key overrides the location when set (the /// preferences dialog's cache-directory setting); an empty/absent value /// keeps the default. /// /// Single-lib unification: this used to live in the oakrender crate's /// `bridge::common` fallback (see `docs/zh/plans/riir/single-lib.md`); /// oaknode and oakrender both call it directly now. pub fn default_disk_cache_path() -> String { // A configured override wins (whitespace-only counts as absent). 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() }