mod cancel;
pub mod checkpoint;
mod driver;
pub(crate) mod observer;
pub(crate) mod path;
pub(crate) mod sqpk;
pub mod vfs;
pub use cancel::CancelToken;
pub use checkpoint::{
Checkpoint, CheckpointPolicy, CheckpointSink, InFlightAddFile, IndexedCheckpoint,
NoopCheckpointSink, SequentialCheckpoint,
};
pub use observer::{ApplyObserver, ChunkEvent, NoopObserver};
pub use vfs::{InMemoryFs, StdFs, Vfs, VfsMetadata, VfsRead, VfsWrite};
use crate::ApplyResult as Result;
use crate::Platform;
use crate::chunk::Chunk;
use crate::chunk::adir::AddDirectory;
use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
use crate::chunk::ddir::DeleteDirectory;
use std::collections::{HashMap, HashSet};
use std::io::{BufWriter, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use tracing::{trace, warn};
pub(crate) struct CachedWriter {
inner: BufWriter<Box<dyn VfsWrite>>,
}
impl std::fmt::Debug for CachedWriter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedWriter").finish_non_exhaustive()
}
}
impl Write for CachedWriter {
#[inline]
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.inner.write(buf)
}
#[inline]
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
self.inner.write_all(buf)
}
#[inline]
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
impl Seek for CachedWriter {
#[inline]
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
#[inline]
fn stream_position(&mut self) -> std::io::Result<u64> {
self.inner.stream_position()
}
}
impl CachedWriter {
pub(crate) fn truncate_to_zero(&mut self) -> std::io::Result<()> {
self.inner.flush()?;
self.inner.get_mut().set_len(0)
}
fn sync_all_inner(&mut self) -> std::io::Result<()> {
self.inner.flush()?;
self.inner.get_mut().sync_all()
}
}
pub(crate) fn validate_checkpoint_policy(policy: CheckpointPolicy) {
assert!(
!matches!(policy, CheckpointPolicy::FsyncEveryN(0)),
"CheckpointPolicy::FsyncEveryN(0) is invalid; use CheckpointPolicy::Fsync \
for an every-record fsync cadence"
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum PathKind {
Dat,
Index,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct PathCacheKey {
pub(crate) main_id: u16,
pub(crate) sub_id: u16,
pub(crate) file_id: u32,
pub(crate) kind: PathKind,
}
pub const DEFAULT_MAX_CACHED_FDS: usize = 256;
pub const DEFAULT_BUFFER_CAPACITY: usize = 64 * 1024;
pub struct ApplyConfig {
game_path: PathBuf,
platform: Platform,
ignore_missing: bool,
ignore_old_mismatch: bool,
vfs: Box<dyn Vfs>,
observer: Box<dyn ApplyObserver>,
checkpoint_sink: Box<dyn CheckpointSink>,
cancel_token: Option<CancelToken>,
max_cached_fds: usize,
buffer_capacity: usize,
}
impl std::fmt::Debug for ApplyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApplyConfig")
.field("game_path", &self.game_path)
.field("platform", &self.platform)
.field("ignore_missing", &self.ignore_missing)
.field("ignore_old_mismatch", &self.ignore_old_mismatch)
.field("vfs", &"<dyn Vfs>")
.field("observer", &"<dyn ApplyObserver>")
.field("checkpoint_sink", &"<dyn CheckpointSink>")
.field("cancel_token", &self.cancel_token)
.field("max_cached_fds", &self.max_cached_fds)
.field("buffer_capacity", &self.buffer_capacity)
.finish()
}
}
impl ApplyConfig {
pub fn new(game_path: impl Into<PathBuf>) -> Self {
Self {
game_path: game_path.into(),
platform: Platform::Win32,
ignore_missing: false,
ignore_old_mismatch: false,
vfs: Box::new(StdFs::new()),
observer: Box::new(NoopObserver),
checkpoint_sink: Box::new(NoopCheckpointSink),
cancel_token: None,
max_cached_fds: DEFAULT_MAX_CACHED_FDS,
buffer_capacity: DEFAULT_BUFFER_CAPACITY,
}
}
#[must_use]
pub fn max_cached_fds(&self) -> usize {
self.max_cached_fds
}
#[must_use]
pub fn buffer_capacity(&self) -> usize {
self.buffer_capacity
}
#[must_use]
pub fn game_path(&self) -> &Path {
&self.game_path
}
#[must_use]
pub fn platform(&self) -> Platform {
self.platform
}
#[must_use]
pub fn ignore_missing(&self) -> bool {
self.ignore_missing
}
#[must_use]
pub fn ignore_old_mismatch(&self) -> bool {
self.ignore_old_mismatch
}
#[must_use]
pub fn with_platform(mut self, platform: Platform) -> Self {
self.platform = platform;
self
}
#[must_use]
pub fn with_ignore_missing(mut self, v: bool) -> Self {
self.ignore_missing = v;
self
}
#[must_use]
pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
self.ignore_old_mismatch = v;
self
}
#[must_use]
pub fn with_vfs(mut self, vfs: impl Vfs + 'static) -> Self {
self.vfs = Box::new(vfs);
self
}
#[must_use]
pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
self.observer = Box::new(observer);
self
}
#[must_use]
pub fn with_cancel_token(mut self, token: CancelToken) -> Self {
self.cancel_token = Some(token);
self
}
#[must_use]
pub fn with_checkpoint_sink(mut self, sink: impl CheckpointSink + 'static) -> Self {
validate_checkpoint_policy(sink.policy());
self.checkpoint_sink = Box::new(sink);
self
}
pub(crate) fn set_boxed_observer(&mut self, observer: Box<dyn ApplyObserver>) {
self.observer = observer;
}
pub(crate) fn set_boxed_checkpoint_sink(&mut self, sink: Box<dyn CheckpointSink>) {
validate_checkpoint_policy(sink.policy());
self.checkpoint_sink = sink;
}
pub(crate) fn set_boxed_vfs(&mut self, vfs: Box<dyn Vfs>) {
self.vfs = vfs;
}
pub(crate) fn set_cancel_token(&mut self, token: CancelToken) {
self.cancel_token = Some(token);
}
#[must_use]
pub fn with_max_cached_fds(mut self, n: usize) -> Self {
assert!(n > 0, "with_max_cached_fds(0) is invalid");
self.max_cached_fds = n;
self
}
#[must_use]
pub fn with_buffer_capacity(mut self, bytes: usize) -> Self {
assert!(bytes > 0, "with_buffer_capacity(0) is invalid");
self.buffer_capacity = bytes;
self
}
#[must_use]
pub fn into_session(self) -> ApplySession {
ApplySession::new(self)
}
}
pub struct ApplySession {
config: ApplyConfig,
pub(crate) file_cache: HashMap<PathBuf, CachedWriter>,
pub(crate) dirs_created: HashSet<PathBuf>,
pub(crate) path_cache: HashMap<PathCacheKey, PathBuf>,
pub(crate) decompressor: flate2::Decompress,
pub(crate) checkpoints_since_fsync: u32,
#[cfg(any(test, feature = "test-utils"))]
pub(crate) test_flush_count: usize,
#[cfg(any(test, feature = "test-utils"))]
pub(crate) test_sync_count: usize,
pub(crate) current_chunk_index: u64,
pub(crate) current_chunk_bytes_read: u64,
pub(crate) patch_name: Option<String>,
pub(crate) patch_size: Option<u64>,
}
impl std::fmt::Debug for ApplySession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("ApplySession");
s.field("config", &self.config)
.field("file_cache_len", &self.file_cache.len())
.field("dirs_created_len", &self.dirs_created.len())
.field("path_cache_len", &self.path_cache.len())
.field("decompressor", &"<flate2::Decompress>")
.field("checkpoints_since_fsync", &self.checkpoints_since_fsync)
.field("current_chunk_index", &self.current_chunk_index)
.field("current_chunk_bytes_read", &self.current_chunk_bytes_read)
.field("patch_name", &self.patch_name)
.field("patch_size", &self.patch_size);
#[cfg(any(test, feature = "test-utils"))]
s.field("test_flush_count", &self.test_flush_count)
.field("test_sync_count", &self.test_sync_count);
s.finish()
}
}
impl ApplySession {
fn new(config: ApplyConfig) -> Self {
Self {
config,
file_cache: HashMap::new(),
dirs_created: HashSet::new(),
path_cache: HashMap::new(),
decompressor: flate2::Decompress::new(false),
checkpoints_since_fsync: 0,
#[cfg(any(test, feature = "test-utils"))]
test_flush_count: 0,
#[cfg(any(test, feature = "test-utils"))]
test_sync_count: 0,
current_chunk_index: 0,
current_chunk_bytes_read: 0,
patch_name: None,
patch_size: None,
}
}
#[must_use]
pub fn config(&self) -> &ApplyConfig {
&self.config
}
#[must_use]
pub fn game_path(&self) -> &Path {
&self.config.game_path
}
#[must_use]
pub fn platform(&self) -> Platform {
self.config.platform
}
#[must_use]
pub fn ignore_missing(&self) -> bool {
self.config.ignore_missing
}
#[must_use]
pub fn ignore_old_mismatch(&self) -> bool {
self.config.ignore_old_mismatch
}
pub(crate) fn vfs(&self) -> &dyn Vfs {
&*self.config.vfs
}
#[cfg(any(test, feature = "test-utils"))]
#[doc(hidden)]
#[must_use]
pub fn test_counters(&self) -> (usize, usize) {
(self.test_flush_count, self.test_sync_count)
}
pub(crate) fn set_platform(&mut self, platform: Platform) {
self.config.platform = platform;
}
pub(crate) fn set_ignore_missing(&mut self, v: bool) {
self.config.ignore_missing = v;
}
pub(crate) fn set_ignore_old_mismatch(&mut self, v: bool) {
self.config.ignore_old_mismatch = v;
}
pub(crate) fn observer_mut(&mut self) -> &mut dyn ApplyObserver {
&mut *self.config.observer
}
pub(crate) fn cancel_requested(&mut self) -> bool {
if let Some(token) = self.config.cancel_token.as_ref() {
if token.is_cancelled() {
return true;
}
}
self.config.observer.should_cancel()
}
pub fn sync_all(&mut self) -> std::io::Result<()> {
#[cfg(any(test, feature = "test-utils"))]
{
self.test_sync_count += 1;
}
let mut first_err: Option<std::io::Error> = None;
for writer in self.file_cache.values_mut() {
if let Err(e) = writer.sync_all_inner() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
pub(crate) fn record_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<()> {
self.config.checkpoint_sink.record(checkpoint)?;
match self.config.checkpoint_sink.policy() {
CheckpointPolicy::Flush => {
self.flush()?;
}
CheckpointPolicy::Fsync => {
self.sync_all()?;
self.checkpoints_since_fsync = 0;
}
CheckpointPolicy::FsyncEveryN(n) => {
debug_assert!(n >= 1, "FsyncEveryN(0) must be rejected at install time");
self.checkpoints_since_fsync = self.checkpoints_since_fsync.saturating_add(1);
if self.checkpoints_since_fsync >= n {
self.sync_all()?;
self.checkpoints_since_fsync = 0;
} else {
self.flush()?;
}
}
}
Ok(())
}
pub(crate) fn record_checkpoint_mid_block(&mut self, checkpoint: &Checkpoint) -> Result<()> {
self.config.checkpoint_sink.record(checkpoint)?;
Ok(())
}
pub(crate) fn open_cached(&mut self, path: &Path) -> std::io::Result<&mut CachedWriter> {
if self.file_cache.contains_key(path) {
return Ok(self
.file_cache
.get_mut(path)
.expect("contains_key returned true above"));
}
if self.file_cache.len() >= self.config.max_cached_fds {
self.drain_and_flush()?;
}
let handle = self.config.vfs.open_write(path)?;
let writer = CachedWriter {
inner: BufWriter::with_capacity(self.config.buffer_capacity, handle),
};
Ok(self.file_cache.entry(path.to_path_buf()).or_insert(writer))
}
pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
if let Some(mut writer) = self.file_cache.remove(path) {
writer.flush()?;
}
Ok(())
}
pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
self.drain_and_flush()
}
pub(crate) fn ensure_dir_all(&mut self, path: &Path) -> std::io::Result<()> {
if self.dirs_created.contains(path) {
return Ok(());
}
self.config.vfs.create_dir_all(path)?;
self.dirs_created.insert(path.to_path_buf());
Ok(())
}
pub(crate) fn invalidate_dirs_created(&mut self) {
self.dirs_created.clear();
}
pub(crate) fn invalidate_path_cache(&mut self) {
self.path_cache.clear();
}
fn drain_and_flush(&mut self) -> std::io::Result<()> {
let mut first_err: Option<std::io::Error> = None;
for (_, mut writer) in self.file_cache.drain() {
if let Err(e) = writer.flush() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
pub fn flush(&mut self) -> std::io::Result<()> {
#[cfg(any(test, feature = "test-utils"))]
{
self.test_flush_count += 1;
}
let mut first_err: Option<std::io::Error> = None;
for writer in self.file_cache.values_mut() {
if let Err(e) = writer.flush() {
first_err.get_or_insert(e);
}
}
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
}
impl Chunk {
pub fn apply(&self, session: &mut ApplySession) -> Result<()> {
match self {
Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
Chunk::Sqpk(c) => c.apply(session),
Chunk::ApplyOption(c) => apply_option(c, session),
Chunk::AddDirectory(c) => apply_add_directory(c, session),
Chunk::DeleteDirectory(c) => apply_delete_directory(c, session),
}
}
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn apply_option(opt: &ApplyOption, session: &mut ApplySession) -> Result<()> {
trace!(kind = ?opt.kind, value = opt.value, "apply option");
match opt.kind {
ApplyOptionKind::IgnoreMissing => session.set_ignore_missing(opt.value),
ApplyOptionKind::IgnoreOldMismatch => session.set_ignore_old_mismatch(opt.value),
}
Ok(())
}
pub(crate) fn apply_add_directory(c: &AddDirectory, session: &mut ApplySession) -> Result<()> {
trace!(name = %c.name, "create directory");
let path = session.game_path().join(&c.name);
session.ensure_dir_all(&path)?;
Ok(())
}
pub(crate) fn apply_delete_directory(
c: &DeleteDirectory,
session: &mut ApplySession,
) -> Result<()> {
let path = session.game_path().join(&c.name);
match session.vfs().remove_dir(&path) {
Ok(()) => {
trace!(name = %c.name, "delete directory");
session.invalidate_dirs_created();
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && session.ignore_missing() => {
warn!(name = %c.name, "delete directory: not found, ignored");
Ok(())
}
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(path: impl Into<PathBuf>) -> ApplySession {
ApplyConfig::new(path).into_session()
}
#[test]
fn cache_eviction_clears_all_entries_when_at_capacity() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
for i in 0..DEFAULT_MAX_CACHED_FDS {
s.open_cached(&tmp.path().join(format!("{i}.dat"))).unwrap();
}
assert_eq!(s.file_cache.len(), DEFAULT_MAX_CACHED_FDS);
s.open_cached(&tmp.path().join("new.dat")).unwrap();
assert_eq!(s.file_cache.len(), 1);
}
#[test]
fn cache_hit_does_not_trigger_eviction_when_full() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
for i in 0..DEFAULT_MAX_CACHED_FDS {
s.open_cached(&tmp.path().join(format!("{i}.dat"))).unwrap();
}
s.open_cached(&tmp.path().join("0.dat")).unwrap();
assert_eq!(s.file_cache.len(), DEFAULT_MAX_CACHED_FDS);
}
#[test]
fn evict_cached_removes_only_target_path() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
let a = tmp.path().join("a.dat");
let b = tmp.path().join("b.dat");
s.open_cached(&a).unwrap();
s.open_cached(&b).unwrap();
assert_eq!(s.file_cache.len(), 2);
s.evict_cached(&a).unwrap();
assert_eq!(s.file_cache.len(), 1);
assert!(s.file_cache.contains_key(&b));
}
#[test]
fn evict_cached_is_noop_for_absent_path() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
s.open_cached(&tmp.path().join("a.dat")).unwrap();
s.evict_cached(&tmp.path().join("nonexistent.dat")).unwrap();
assert_eq!(s.file_cache.len(), 1);
}
#[test]
fn clear_file_cache_removes_all_handles() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
s.open_cached(&tmp.path().join("a.dat")).unwrap();
s.open_cached(&tmp.path().join("b.dat")).unwrap();
s.clear_file_cache().unwrap();
assert_eq!(s.file_cache.len(), 0);
}
#[test]
fn default_max_cached_fds_matches_constant() {
let cfg = ApplyConfig::new("/irrelevant");
assert_eq!(cfg.max_cached_fds(), DEFAULT_MAX_CACHED_FDS);
}
#[test]
fn default_buffer_capacity_matches_constant() {
let cfg = ApplyConfig::new("/irrelevant");
assert_eq!(cfg.buffer_capacity(), DEFAULT_BUFFER_CAPACITY);
}
#[test]
fn with_max_cached_fds_overrides_default() {
let cfg = ApplyConfig::new("/irrelevant").with_max_cached_fds(16);
assert_eq!(cfg.max_cached_fds(), 16);
}
#[test]
fn with_buffer_capacity_overrides_default() {
let cfg = ApplyConfig::new("/irrelevant").with_buffer_capacity(1 << 20);
assert_eq!(cfg.buffer_capacity(), 1 << 20);
}
#[test]
#[should_panic(expected = "with_max_cached_fds(0) is invalid")]
fn with_max_cached_fds_zero_panics() {
let _ = ApplyConfig::new("/irrelevant").with_max_cached_fds(0);
}
#[test]
#[should_panic(expected = "with_buffer_capacity(0) is invalid")]
fn with_buffer_capacity_zero_panics() {
let _ = ApplyConfig::new("/irrelevant").with_buffer_capacity(0);
}
#[test]
fn custom_max_cached_fds_changes_eviction_threshold() {
let tmp = tempfile::tempdir().unwrap();
let cfg = ApplyConfig::new(tmp.path()).with_max_cached_fds(4);
let mut s = cfg.into_session();
for i in 0..4 {
s.open_cached(&tmp.path().join(format!("{i}.dat"))).unwrap();
}
assert_eq!(s.file_cache.len(), 4);
s.open_cached(&tmp.path().join("new.dat")).unwrap();
assert_eq!(s.file_cache.len(), 1);
}
#[test]
fn game_path_returns_install_root_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let cfg = ApplyConfig::new(tmp.path());
assert_eq!(cfg.game_path(), tmp.path());
}
#[test]
fn default_platform_is_win32() {
let cfg = ApplyConfig::new("/irrelevant");
assert_eq!(cfg.platform(), Platform::Win32);
}
#[test]
fn with_platform_overrides_default() {
let cfg = ApplyConfig::new("/irrelevant").with_platform(Platform::Ps4);
assert_eq!(cfg.platform(), Platform::Ps4);
}
#[test]
fn default_ignore_missing_is_false() {
let cfg = ApplyConfig::new("/irrelevant");
assert!(!cfg.ignore_missing());
}
#[test]
fn with_ignore_missing_toggles_flag_both_ways() {
let cfg = ApplyConfig::new("/irrelevant").with_ignore_missing(true);
assert!(cfg.ignore_missing());
let cfg = cfg.with_ignore_missing(false);
assert!(!cfg.ignore_missing());
}
#[test]
fn default_ignore_old_mismatch_is_false() {
let cfg = ApplyConfig::new("/irrelevant");
assert!(!cfg.ignore_old_mismatch());
}
#[test]
fn with_ignore_old_mismatch_toggles_flag_both_ways() {
let cfg = ApplyConfig::new("/irrelevant").with_ignore_old_mismatch(true);
assert!(cfg.ignore_old_mismatch());
let cfg = cfg.with_ignore_old_mismatch(false);
assert!(!cfg.ignore_old_mismatch());
}
#[test]
fn buffered_writes_are_invisible_before_flush() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
let path = tmp.path().join("buffered.dat");
let writer = s.open_cached(&path).unwrap();
writer.write_all(&[0xAB]).unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
s.flush().unwrap();
assert_eq!(std::fs::read(&path).unwrap(), vec![0xAB]);
}
#[test]
fn flush_keeps_handles_in_cache() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
s.open_cached(&tmp.path().join("a.dat")).unwrap();
s.open_cached(&tmp.path().join("b.dat")).unwrap();
s.flush().unwrap();
assert_eq!(s.file_cache.len(), 2);
}
#[test]
fn evict_cached_flushes_pending_writes_to_disk() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
let path = tmp.path().join("evict.dat");
let writer = s.open_cached(&path).unwrap();
writer.write_all(b"queued").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
s.evict_cached(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"queued");
assert!(!s.file_cache.contains_key(&path));
}
#[test]
fn clear_file_cache_flushes_every_pending_write() {
use std::io::Write;
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
let a = tmp.path().join("a.dat");
let b = tmp.path().join("b.dat");
s.open_cached(&a).unwrap().write_all(b"AA").unwrap();
s.open_cached(&b).unwrap().write_all(b"BB").unwrap();
s.clear_file_cache().unwrap();
assert_eq!(std::fs::read(&a).unwrap(), b"AA");
assert_eq!(std::fs::read(&b).unwrap(), b"BB");
assert!(s.file_cache.is_empty());
}
#[test]
fn apply_session_debug_renders_all_fields() {
let tmp = tempfile::tempdir().unwrap();
let s = ApplyConfig::new(tmp.path())
.with_platform(Platform::Ps4)
.with_ignore_missing(true)
.into_session();
let rendered = format!("{s:?}");
for needle in [
"ApplySession",
"ApplyConfig",
"game_path",
"platform",
"Ps4",
"ignore_missing",
"true",
"ignore_old_mismatch",
"file_cache_len",
"path_cache_len",
"decompressor",
] {
assert!(
rendered.contains(needle),
"Debug output must mention {needle:?}; got: {rendered}"
);
}
}
#[test]
fn delete_directory_success_removes_existing_dir() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("to_remove");
std::fs::create_dir(&target).unwrap();
let mut s = session(tmp.path());
apply_delete_directory(
&DeleteDirectory {
name: "to_remove".into(),
},
&mut s,
)
.expect("delete on an existing directory must succeed");
assert!(!target.exists());
}
#[test]
fn ensure_dir_all_cache_hit_returns_early_without_syscall() {
let tmp = tempfile::tempdir().unwrap();
let mut s = session(tmp.path());
let path = tmp.path().join("cached_dir");
s.ensure_dir_all(&path).unwrap();
assert!(path.is_dir());
assert_eq!(s.dirs_created.len(), 1);
let p2 = tmp.path().join("cached_dir");
s.ensure_dir_all(&p2).unwrap();
assert_eq!(s.dirs_created.len(), 1);
}
#[test]
fn in_memory_fs_records_directory_creation() {
let fs = InMemoryFs::new();
let mut s = ApplyConfig::new("/g").with_vfs(fs.clone()).into_session();
apply_add_directory(&AddDirectory { name: "sub".into() }, &mut s).unwrap();
assert!(fs.snapshot_dirs().contains(&PathBuf::from("/g/sub")));
}
}