Skip to main content

zipatch_rs/apply/
mod.rs

1//! Filesystem application of parsed `ZiPatch` chunks.
2//!
3//! # Parse / apply separation
4//!
5//! The crate is intentionally split into two independent layers:
6//!
7//! - **Parsing** (`src/chunk/`) — reads the binary wire format and produces
8//!   [`Chunk`] values. Nothing in the parser allocates file handles, stats
9//!   paths, or performs I/O against the install tree.
10//! - **Applying** (this module) — takes a stream of [`Chunk`] values and
11//!   writes the patch changes to disk.
12//!
13//! The only bridge between the two layers is the [`Apply`] trait, which every
14//! chunk type implements. Callers that only need to inspect patch contents can
15//! use the parser without ever touching this module.
16//!
17//! # `ApplyContext`
18//!
19//! [`ApplyContext`] holds all mutable apply-time state:
20//!
21//! - **Install root** — the absolute path to the game installation directory.
22//!   All `SqPack` paths (`sqpack/<expansion>/...`) are resolved relative to
23//!   this root by the internal path submodule.
24//! - **Target platform** — selects the `win32`/`ps3`/`ps4` subfolder suffix
25//!   used in `SqPack` file paths. Defaults to [`Platform::Win32`] and can be
26//!   overridden either at construction time with [`ApplyContext::with_platform`]
27//!   or at apply time when a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk is
28//!   encountered.
29//! - **Ignore flags** — control whether missing files and old-data mismatches
30//!   produce errors or logged warnings. `SqpkTargetInfo` chunks set these via
31//!   the stream; callers can also pre-configure them.
32//! - **File-handle cache** — a bounded map of open file handles. Because a
33//!   typical patch applies dozens of chunks to the same `.dat` file,
34//!   re-opening that file for every chunk would be wasteful. The cache avoids
35//!   this while bounding the number of simultaneously open file descriptors.
36//!   See the [cache section](#file-handle-cache) below.
37//!
38//! # File-handle cache
39//!
40//! Every `Apply` impl that writes to a `SqPack` file calls an internal
41//! `open_cached` method on `ApplyContext` rather than opening the file
42//! directly. The cache transparently returns an existing writable handle or
43//! opens a new one (with `write=true, create=true, truncate=false`).
44//!
45//! Cached handles are wrapped in a [`std::io::BufWriter`] with a 64 KiB
46//! buffer to coalesce the many small writes the SQPK pipeline emits — block
47//! headers, zero-fill runs, decompressed DEFLATE block output — into a
48//! smaller number of `write(2)` syscalls. Apply functions interact with the
49//! buffered writer transparently because `BufWriter` implements both `Write`
50//! and `Seek`. Call [`ApplyContext::flush`] to force buffered data through
51//! to the operating system at a checkpoint of your choosing;
52//! [`ZiPatchReader::apply_to`](crate::ZiPatchReader::apply_to) calls it
53//! automatically before returning.
54//!
55//! The cache is capped at 256 entries. When it is full and a new, uncached
56//! path is requested, **all** cached handles are flushed and closed at once
57//! before the new one is inserted. This is a simple eviction strategy — it
58//! trades some re-open overhead at eviction boundaries for bounded
59//! file-descriptor usage. Memory cost at the cap is 256 × 64 KiB = 16 MiB.
60//!
61//! Callers should not rely on cached handles persisting across arbitrary
62//! chunks. In particular, [`crate::chunk::sqpk::SqpkFile`]'s `RemoveAll`
63//! operation flushes all cached handles before bulk-deleting files to ensure
64//! no open handles survive into the deletion window (which matters on
65//! Windows). Similarly, `DeleteFile` evicts the cached handle for the
66//! specific path being removed.
67//!
68//! # Ordering and idempotency
69//!
70//! Chunks **must** be applied in stream order. The `ZiPatch` format is a
71//! sequential log, not a random-access manifest: later chunks may depend on
72//! filesystem state produced by earlier ones (e.g. an `AddFile` that writes
73//! blocks into a file created by an earlier `MakeDirTree` or `AddDirectory`).
74//!
75//! Apply operations are **not idempotent** in general. Seeking to an offset
76//! and writing data is idempotent if the same data is written, but
77//! `RemoveAll` is destructive and `DeleteFile` can fail if the file is
78//! already gone (unless `ignore_missing` is set). Partial application
79//! followed by a retry requires careful state tracking at a higher level;
80//! this crate does not provide transactional semantics.
81//!
82//! # Errors
83//!
84//! Every [`Apply::apply`] call returns [`crate::Result`], which is
85//! `Result<(), `[`crate::ZiPatchError`]`>`. Errors propagate from:
86//!
87//! - `std::io::Error` — filesystem failures (permissions, missing parent
88//!   directories, disk full, etc.) wrapped as [`crate::ZiPatchError::Io`].
89//! - [`crate::ZiPatchError::NegativeFileOffset`] — a `SqpkFile` chunk
90//!   carried a negative `file_offset` that cannot be converted to a seek
91//!   position.
92//!
93//! On error, the apply operation aborts at the failing chunk. Any changes
94//! already applied to the filesystem are **not** rolled back.
95//!
96//! # Progress and cancellation
97//!
98//! Install an [`ApplyObserver`] via [`ApplyContext::with_observer`] to be
99//! notified after each top-level chunk applies and to signal cancellation
100//! mid-stream. The observer's
101//! [`on_chunk_applied`](ApplyObserver::on_chunk_applied) method receives a
102//! [`ChunkEvent`] with the chunk index, 4-byte tag, and running byte count;
103//! its [`should_cancel`](ApplyObserver::should_cancel) predicate is polled
104//! between blocks inside long-running chunks so that aborting a multi-
105//! hundred-MB `SqpkFile` `AddFile` does not have to wait for the whole
106//! chunk to finish. Without an explicit observer, [`ApplyContext`] uses
107//! [`NoopObserver`] and the existing apply path pays nothing.
108//!
109//! # Example
110//!
111//! ```no_run
112//! use std::fs::File;
113//! use zipatch_rs::{ApplyContext, ZiPatchReader};
114//!
115//! let patch_file = File::open("game.patch").unwrap();
116//! let mut ctx = ApplyContext::new("/opt/ffxiv/game");
117//!
118//! ZiPatchReader::new(patch_file)
119//!     .unwrap()
120//!     .apply_to(&mut ctx)
121//!     .unwrap();
122//! ```
123
124pub(crate) mod observer;
125pub(crate) mod path;
126pub(crate) mod sqpk;
127
128pub use observer::{ApplyObserver, ChunkEvent, NoopObserver};
129
130use crate::Platform;
131use crate::Result;
132use crate::chunk::Chunk;
133use crate::chunk::adir::AddDirectory;
134use crate::chunk::aply::{ApplyOption, ApplyOptionKind};
135use crate::chunk::ddir::DeleteDirectory;
136use std::collections::HashMap;
137use std::fs::{File, OpenOptions};
138use std::io::{BufWriter, Write};
139use std::path::{Path, PathBuf};
140use tracing::{debug, trace, warn};
141
142const MAX_CACHED_FDS: usize = 256;
143
144// 64 KiB chosen to comfortably absorb the largest single writes the SQPK
145// pipeline emits (1 KiB header chunks, DEFLATE block outputs up to ~32 KiB,
146// zero-fill runs from `write_zeros`) without splitting them. Memory ceiling
147// at the FD cap is 256 * 64 KiB = 16 MiB, which is trivial for a desktop
148// launcher and the only realistic consumer of this library.
149const WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
150
151/// Apply-time state: install root, target platform, flag toggles, and the
152/// internal file-handle cache used by SQPK writers.
153///
154/// # Construction
155///
156/// Build with [`ApplyContext::new`], then chain the `with_*` builder methods
157/// to override defaults:
158///
159/// ```
160/// use zipatch_rs::{ApplyContext, Platform};
161///
162/// let ctx = ApplyContext::new("/opt/ffxiv/game")
163///     .with_platform(Platform::Win32)
164///     .with_ignore_missing(true);
165///
166/// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
167/// assert_eq!(ctx.platform(), Platform::Win32);
168/// assert!(ctx.ignore_missing());
169/// ```
170///
171/// # Platform mutation
172///
173/// The platform defaults to [`Platform::Win32`]. If the patch stream contains
174/// a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk, applying it overwrites
175/// [`ApplyContext::platform`] with the platform declared in the chunk. This is
176/// the normal case: real FFXIV patches begin with a `TargetInfo` chunk that
177/// pins the platform, so the default is rarely used in practice.
178///
179/// Set the platform explicitly with [`ApplyContext::with_platform`] when you
180/// know the target in advance or are processing a synthetic patch.
181///
182/// # Flag mutation
183///
184/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
185/// can also be overwritten mid-stream by `ApplyOption` chunks embedded in the
186/// patch file. Set initial values with the `with_ignore_*` builder methods.
187///
188/// # File-handle cache
189///
190/// Internally, `ApplyContext` maintains a bounded map of open file handles
191/// keyed by absolute path. The cache is an optimisation: a patch that writes
192/// many chunks into the same `.dat` file re-uses a single handle rather than
193/// opening and closing the file for every write.
194///
195/// The cache is capped at 256 entries. When that limit is reached and a new
196/// path is needed, **all** entries are evicted at once. Handles are also
197/// evicted explicitly before deleting a file (see `DeleteFile`) and before a
198/// `RemoveAll` bulk operation.
199pub struct ApplyContext {
200    pub(crate) game_path: PathBuf,
201    /// The target platform. Defaults to `Win32`. Note: `SqpkTargetInfo` chunks
202    /// in the patch stream will override this value when applied.
203    pub(crate) platform: Platform,
204    pub(crate) ignore_missing: bool,
205    pub(crate) ignore_old_mismatch: bool,
206    // Capped at MAX_CACHED_FDS entries; cleared wholesale when full to bound open FD count.
207    // Each handle is wrapped in a `BufWriter` to coalesce the many small
208    // writes the SQPK pipeline emits (block headers, zero-fill runs) into a
209    // smaller number of `write(2)` syscalls. `BufWriter` implements both
210    // `Write` and `Seek`, so apply functions interact with it transparently;
211    // operations that need the raw `File` (currently only `set_len`) call
212    // `get_mut()` after an explicit `flush()`.
213    //
214    // `pub(crate)` so the SQPK `AddFile` apply loop can split-borrow it next
215    // to `observer` for between-block cancellation polling.
216    pub(crate) file_cache: HashMap<PathBuf, BufWriter<File>>,
217    // Observer for progress/cancellation. Defaults to a no-op; replaced by
218    // `with_observer`. Stored as a boxed trait object so that the public
219    // `ApplyContext` type stays non-generic and remains nameable in downstream
220    // signatures (`gaveloc-patcher` passes contexts across module boundaries).
221    pub(crate) observer: Box<dyn ApplyObserver>,
222}
223
224// Hand-written so we don't need `ApplyObserver: Debug` as a supertrait —
225// adding supertraits is a SemVer break, and forcing every user observer to
226// implement `Debug` would be a needless ergonomic tax. The observer field
227// is summarised as an opaque placeholder.
228impl std::fmt::Debug for ApplyContext {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("ApplyContext")
231            .field("game_path", &self.game_path)
232            .field("platform", &self.platform)
233            .field("ignore_missing", &self.ignore_missing)
234            .field("ignore_old_mismatch", &self.ignore_old_mismatch)
235            .field("file_cache_len", &self.file_cache.len())
236            .field("observer", &"<dyn ApplyObserver>")
237            .finish()
238    }
239}
240
241impl ApplyContext {
242    /// Create a context targeting the given game install directory.
243    ///
244    /// Defaults: platform is [`Platform::Win32`], both ignore-flags are off.
245    ///
246    /// Use the `with_*` builder methods to change these defaults before
247    /// applying the first chunk.
248    ///
249    /// # Example
250    ///
251    /// ```
252    /// use zipatch_rs::ApplyContext;
253    ///
254    /// let ctx = ApplyContext::new("/opt/ffxiv/game");
255    /// assert_eq!(ctx.game_path().to_str().unwrap(), "/opt/ffxiv/game");
256    /// ```
257    pub fn new(game_path: impl Into<PathBuf>) -> Self {
258        Self {
259            game_path: game_path.into(),
260            platform: Platform::Win32,
261            ignore_missing: false,
262            ignore_old_mismatch: false,
263            file_cache: HashMap::new(),
264            observer: Box::new(NoopObserver),
265        }
266    }
267
268    /// Returns the game installation directory.
269    ///
270    /// All file paths produced during apply are relative to this root.
271    #[must_use]
272    pub fn game_path(&self) -> &std::path::Path {
273        &self.game_path
274    }
275
276    /// Returns the current target platform.
277    ///
278    /// This value may change during apply if the patch stream contains a
279    /// [`crate::chunk::sqpk::SqpkTargetInfo`] chunk.
280    #[must_use]
281    pub fn platform(&self) -> Platform {
282        self.platform
283    }
284
285    /// Returns whether missing files are silently ignored during apply.
286    ///
287    /// When `true`, operations that target a file that does not exist log a
288    /// warning instead of returning an error. This flag may be overwritten
289    /// mid-stream by an `ApplyOption` chunk.
290    #[must_use]
291    pub fn ignore_missing(&self) -> bool {
292        self.ignore_missing
293    }
294
295    /// Returns whether old-data mismatches are silently ignored during apply.
296    ///
297    /// When `true`, apply operations that detect a checksum or data mismatch
298    /// against the existing on-disk content proceed without error. This flag
299    /// may be overwritten mid-stream by an `ApplyOption` chunk.
300    #[must_use]
301    pub fn ignore_old_mismatch(&self) -> bool {
302        self.ignore_old_mismatch
303    }
304
305    /// Sets the target platform. Defaults to [`Platform::Win32`].
306    ///
307    /// The platform determines the directory suffix used when resolving `SqPack`
308    /// file paths (`win32`, `ps3`, or `ps4`).
309    ///
310    /// Note: a [`crate::chunk::sqpk::SqpkTargetInfo`] chunk encountered during
311    /// apply will override this value.
312    #[must_use]
313    pub fn with_platform(mut self, platform: Platform) -> Self {
314        self.platform = platform;
315        self
316    }
317
318    /// Silently ignore missing files instead of returning an error during apply.
319    ///
320    /// When `false` (the default), any apply operation that cannot find its
321    /// target file returns [`crate::ZiPatchError::Io`] with kind
322    /// [`std::io::ErrorKind::NotFound`].
323    ///
324    /// When `true`, those failures are demoted to `warn!`-level tracing events.
325    #[must_use]
326    pub fn with_ignore_missing(mut self, v: bool) -> Self {
327        self.ignore_missing = v;
328        self
329    }
330
331    /// Silently ignore old-data mismatches instead of returning an error during apply.
332    ///
333    /// When `false` (the default), an apply operation that detects that the
334    /// on-disk data does not match the expected "before" state returns an error.
335    ///
336    /// When `true`, the mismatch is logged at `warn!` level and the operation
337    /// continues.
338    #[must_use]
339    pub fn with_ignore_old_mismatch(mut self, v: bool) -> Self {
340        self.ignore_old_mismatch = v;
341        self
342    }
343
344    /// Install an [`ApplyObserver`] for progress reporting and cancellation.
345    ///
346    /// The observer's [`on_chunk_applied`](ApplyObserver::on_chunk_applied)
347    /// method is called after each top-level chunk; its
348    /// [`should_cancel`](ApplyObserver::should_cancel) method is polled
349    /// inside long-running chunks (currently the
350    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) block-write loop) so that
351    /// cancellation is observable mid-chunk on multi-hundred-MB payloads.
352    ///
353    /// Returning [`ControlFlow::Break`](std::ops::ControlFlow::Break) from
354    /// `on_chunk_applied`, or `true` from `should_cancel`, aborts the apply
355    /// loop with [`crate::ZiPatchError::Cancelled`]. Filesystem changes already
356    /// applied are **not** rolled back.
357    ///
358    /// The default observer is a no-op: parsing-only consumers and existing
359    /// callers that never call `with_observer` pay nothing.
360    ///
361    /// # `'static` bound
362    ///
363    /// The `'static` bound follows from [`ApplyContext`] storing the
364    /// observer in a `Box<dyn ApplyObserver>` — a trait object whose
365    /// lifetime parameter defaults to `'static`. To pass an observer that
366    /// holds a channel sender or similar handle, wrap it in
367    /// `Arc<Mutex<...>>` (which is `'static`) or implement
368    /// [`ApplyObserver`] on a struct that owns the handle directly.
369    #[must_use]
370    pub fn with_observer(mut self, observer: impl ApplyObserver + 'static) -> Self {
371        self.observer = Box::new(observer);
372        self
373    }
374
375    /// Return a writable handle to `path`, opening it if not already cached.
376    ///
377    /// If the cache has reached 256 entries and `path` is not already present,
378    /// all cached handles are flushed and dropped before opening the new one.
379    /// The file is opened with `write=true, create=true, truncate=false` and
380    /// wrapped in a [`BufWriter`] with a 64 KiB buffer.
381    ///
382    /// # Errors
383    ///
384    /// Returns `std::io::Error` if the file cannot be opened, or if the
385    /// flush triggered by cache-full eviction fails on any of the dropped
386    /// handles (e.g. disk full while persisting buffered writes).
387    pub(crate) fn open_cached(&mut self, path: PathBuf) -> std::io::Result<&mut BufWriter<File>> {
388        use std::collections::hash_map::Entry;
389        // Crude eviction: flush + clear all when full to bound open FD count.
390        // Flushing first surfaces write errors (disk full, quota) that would
391        // otherwise be silently swallowed by `BufWriter::drop`.
392        if self.file_cache.len() >= MAX_CACHED_FDS && !self.file_cache.contains_key(&path) {
393            self.drain_and_flush()?;
394        }
395        match self.file_cache.entry(path) {
396            Entry::Occupied(e) => Ok(e.into_mut()),
397            Entry::Vacant(e) => {
398                let file = OpenOptions::new()
399                    .write(true)
400                    .create(true)
401                    .truncate(false)
402                    .open(e.key())?;
403                Ok(e.insert(BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file)))
404            }
405        }
406    }
407
408    /// Flush and remove the cached handle for `path`, if any.
409    ///
410    /// Called before a file is deleted so that the OS handle is closed before
411    /// the unlink (no-op on Linux, required on Windows where an open handle
412    /// prevents deletion). The buffered writes are flushed first so that any
413    /// pending data lands on disk before the close.
414    ///
415    /// If no handle is cached for `path`, returns `Ok(())`.
416    ///
417    /// # Errors
418    ///
419    /// Returns `std::io::Error` if flushing the buffered writes fails.
420    pub(crate) fn evict_cached(&mut self, path: &Path) -> std::io::Result<()> {
421        if let Some(mut writer) = self.file_cache.remove(path) {
422            writer.flush()?;
423        }
424        Ok(())
425    }
426
427    /// Flush and drop every cached file handle.
428    ///
429    /// Called by `RemoveAll` before bulk-deleting an expansion folder's files
430    /// to ensure no lingering open handles survive into the deletion window,
431    /// and by `flush()` to provide a public durability checkpoint.
432    ///
433    /// # Errors
434    ///
435    /// Returns the first `std::io::Error` produced by any handle's flush.
436    /// Remaining handles are still flushed and cleared even if an earlier
437    /// one failed; the cache is always empty on return.
438    pub(crate) fn clear_file_cache(&mut self) -> std::io::Result<()> {
439        self.drain_and_flush()
440    }
441
442    /// Flush every cached writer's buffer, then drain the cache.
443    ///
444    /// Used by both [`Self::clear_file_cache`] and the cache-full path inside
445    /// [`Self::open_cached`]. Distinct from [`Self::flush`], which flushes in
446    /// place without dropping the handles.
447    fn drain_and_flush(&mut self) -> std::io::Result<()> {
448        let mut first_err: Option<std::io::Error> = None;
449        for (_, mut writer) in self.file_cache.drain() {
450            if let Err(e) = writer.flush() {
451                first_err.get_or_insert(e);
452            }
453        }
454        match first_err {
455            Some(e) => Err(e),
456            None => Ok(()),
457        }
458    }
459
460    /// Flush every buffered write through to the operating system.
461    ///
462    /// Forces any data still sitting in [`BufWriter`] buffers (one per cached
463    /// `SqPack` file) to be written via `write(2)`. Open handles are retained
464    /// — this is a durability checkpoint, not a cache eviction. Subsequent
465    /// chunks targeting the same files reuse the existing handles.
466    ///
467    /// `apply_to` calls this automatically before returning so successful
468    /// completion of a patch implies all writes have reached the OS. Explicit
469    /// calls are useful when applying chunks one at a time via
470    /// [`Apply::apply`] and reading the resulting file state in between, or
471    /// when implementing a custom apply driver that wants intermediate
472    /// commit points.
473    ///
474    /// This is **not** `fsync`. Data is handed off to the OS but may still
475    /// reside in the page cache; survival across a crash requires
476    /// `File::sync_all` on the underlying handles, which this method does
477    /// not perform.
478    ///
479    /// # Errors
480    ///
481    /// Returns the first `std::io::Error` produced by any writer's flush.
482    /// Remaining writers are still attempted even if an earlier one failed.
483    pub fn flush(&mut self) -> std::io::Result<()> {
484        let mut first_err: Option<std::io::Error> = None;
485        for writer in self.file_cache.values_mut() {
486            if let Err(e) = writer.flush() {
487                first_err.get_or_insert(e);
488            }
489        }
490        match first_err {
491            Some(e) => Err(e),
492            None => Ok(()),
493        }
494    }
495}
496
497/// Applies a parsed chunk to the filesystem via an [`ApplyContext`].
498///
499/// Every top-level [`Chunk`] variant and every
500/// [`crate::chunk::sqpk::SqpkCommand`] variant implements this trait. The
501/// usual entry point is [`Chunk::apply`], which dispatches to the appropriate
502/// implementation.
503///
504/// # Ordering
505///
506/// Chunks must be applied in the order they appear in the patch stream.
507/// The format is a sequential log; later chunks may depend on state produced
508/// by earlier ones.
509///
510/// # Idempotency
511///
512/// Apply operations are **not idempotent** in general. Write operations are
513/// idempotent only if the data payload is identical to what is already on
514/// disk. Destructive operations (`RemoveAll`, `DeleteFile`, `DeleteDirectory`)
515/// are not repeatable without error unless `ignore_missing` is set.
516///
517/// # Errors
518///
519/// Returns [`crate::ZiPatchError`] on any filesystem or data error. The error
520/// is not recovered from; the caller should treat it as fatal for the current
521/// apply session.
522///
523/// # Panics
524///
525/// Implementations do not panic under normal operation. Panics would indicate
526/// a bug in the parsing layer (e.g. a chunk with fields that violate internal
527/// invariants established during parsing).
528pub trait Apply {
529    /// Apply this chunk to `ctx`.
530    ///
531    /// On success, any filesystem changes the chunk describes have been
532    /// written. On error, changes may be partial; the caller is responsible
533    /// for any recovery.
534    fn apply(&self, ctx: &mut ApplyContext) -> Result<()>;
535}
536
537/// Dispatch table for top-level chunk variants.
538///
539/// `FileHeader`, `ApplyFreeSpace`, and `EndOfFile` are metadata or structural
540/// chunks with no filesystem effect; they return `Ok(())` immediately.
541/// All other variants delegate to their specific `Apply` implementation.
542impl Apply for Chunk {
543    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
544        match self {
545            Chunk::FileHeader(_) | Chunk::ApplyFreeSpace(_) | Chunk::EndOfFile => Ok(()),
546            Chunk::Sqpk(c) => c.apply(ctx),
547            Chunk::ApplyOption(c) => c.apply(ctx),
548            Chunk::AddDirectory(c) => c.apply(ctx),
549            Chunk::DeleteDirectory(c) => c.apply(ctx),
550        }
551    }
552}
553
554/// Updates [`ApplyContext`] ignore-flags from the chunk payload.
555///
556/// `ApplyOption` chunks are embedded in the patch stream to toggle
557/// [`ApplyContext::ignore_missing`] and [`ApplyContext::ignore_old_mismatch`]
558/// at specific points during apply. Applying this chunk mutates `ctx` in
559/// place; no filesystem I/O is performed.
560impl Apply for ApplyOption {
561    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
562        debug!(kind = ?self.kind, value = self.value, "apply option");
563        match self.kind {
564            ApplyOptionKind::IgnoreMissing => ctx.ignore_missing = self.value,
565            ApplyOptionKind::IgnoreOldMismatch => ctx.ignore_old_mismatch = self.value,
566        }
567        Ok(())
568    }
569}
570
571/// Creates a directory under the game install root.
572///
573/// Equivalent to `fs::create_dir_all(game_path / name)`. Intermediate
574/// directories are created as needed; the call is idempotent if the directory
575/// already exists.
576///
577/// # Errors
578///
579/// Returns [`crate::ZiPatchError::Io`] if directory creation fails for any
580/// reason other than the directory already existing (e.g. a permission error
581/// or a non-directory file at the path).
582impl Apply for AddDirectory {
583    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
584        trace!(name = %self.name, "create directory");
585        std::fs::create_dir_all(ctx.game_path.join(&self.name))?;
586        Ok(())
587    }
588}
589
590/// Removes a directory from the game install root.
591///
592/// The directory must be **empty**; `remove_dir` (not `remove_dir_all`) is
593/// used intentionally so that stale files inside the directory cause a visible
594/// error rather than silent data loss.
595///
596/// If the directory does not exist and [`ApplyContext::ignore_missing`] is
597/// `true`, the missing directory is logged at `warn!` level and `Ok(())` is
598/// returned. If `ignore_missing` is `false`, the `NotFound` I/O error is
599/// propagated.
600///
601/// # Errors
602///
603/// Returns [`crate::ZiPatchError::Io`] if the removal fails for any reason
604/// other than a missing directory with `ignore_missing = true`.
605impl Apply for DeleteDirectory {
606    fn apply(&self, ctx: &mut ApplyContext) -> Result<()> {
607        match std::fs::remove_dir(ctx.game_path.join(&self.name)) {
608            Ok(()) => {
609                trace!(name = %self.name, "delete directory");
610                Ok(())
611            }
612            Err(e) if e.kind() == std::io::ErrorKind::NotFound && ctx.ignore_missing => {
613                warn!(name = %self.name, "delete directory: not found, ignored");
614                Ok(())
615            }
616            Err(e) => Err(e.into()),
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    // --- Cache semantics ---
626
627    #[test]
628    fn cache_eviction_clears_all_entries_when_at_capacity() {
629        // Fill the cache to exactly MAX_CACHED_FDS, then request one new path.
630        // The eviction must drop all 256 entries and leave only the new one.
631        let tmp = tempfile::tempdir().unwrap();
632        let mut ctx = ApplyContext::new(tmp.path());
633
634        for i in 0..MAX_CACHED_FDS {
635            ctx.open_cached(tmp.path().join(format!("{i}.dat")))
636                .unwrap();
637        }
638        assert_eq!(
639            ctx.file_cache.len(),
640            MAX_CACHED_FDS,
641            "cache should be full before triggering eviction"
642        );
643
644        ctx.open_cached(tmp.path().join("new.dat")).unwrap();
645        assert_eq!(
646            ctx.file_cache.len(),
647            1,
648            "eviction must clear all entries and leave only the new handle"
649        );
650    }
651
652    #[test]
653    fn cache_hit_does_not_trigger_eviction_when_full() {
654        // With a full cache, requesting an *already-cached* path must NOT evict
655        // — the size stays at MAX_CACHED_FDS.
656        let tmp = tempfile::tempdir().unwrap();
657        let mut ctx = ApplyContext::new(tmp.path());
658
659        for i in 0..MAX_CACHED_FDS {
660            ctx.open_cached(tmp.path().join(format!("{i}.dat")))
661                .unwrap();
662        }
663        // Re-request the first path — it is already in the cache.
664        ctx.open_cached(tmp.path().join("0.dat")).unwrap();
665        assert_eq!(
666            ctx.file_cache.len(),
667            MAX_CACHED_FDS,
668            "cache hit on a full cache must not evict anything"
669        );
670    }
671
672    #[test]
673    fn evict_cached_removes_only_target_path() {
674        let tmp = tempfile::tempdir().unwrap();
675        let mut ctx = ApplyContext::new(tmp.path());
676        let a = tmp.path().join("a.dat");
677        let b = tmp.path().join("b.dat");
678        ctx.open_cached(a.clone()).unwrap();
679        ctx.open_cached(b.clone()).unwrap();
680        assert_eq!(ctx.file_cache.len(), 2);
681
682        ctx.evict_cached(&a).unwrap();
683        assert_eq!(
684            ctx.file_cache.len(),
685            1,
686            "evict_cached must remove exactly the targeted path"
687        );
688        assert!(
689            ctx.file_cache.contains_key(&b),
690            "evict_cached must not remove the other path"
691        );
692    }
693
694    #[test]
695    fn evict_cached_is_noop_for_absent_path() {
696        let tmp = tempfile::tempdir().unwrap();
697        let mut ctx = ApplyContext::new(tmp.path());
698        ctx.open_cached(tmp.path().join("a.dat")).unwrap();
699        // Evicting a path that was never inserted must not panic or change the cache.
700        ctx.evict_cached(&tmp.path().join("nonexistent.dat"))
701            .unwrap();
702        assert_eq!(ctx.file_cache.len(), 1);
703    }
704
705    #[test]
706    fn clear_file_cache_removes_all_handles() {
707        let tmp = tempfile::tempdir().unwrap();
708        let mut ctx = ApplyContext::new(tmp.path());
709        ctx.open_cached(tmp.path().join("a.dat")).unwrap();
710        ctx.open_cached(tmp.path().join("b.dat")).unwrap();
711        assert_eq!(ctx.file_cache.len(), 2);
712        ctx.clear_file_cache().unwrap();
713        assert_eq!(
714            ctx.file_cache.len(),
715            0,
716            "clear_file_cache must empty the cache"
717        );
718    }
719
720    // --- Builder accessors ---
721
722    #[test]
723    fn game_path_returns_install_root_unchanged() {
724        let tmp = tempfile::tempdir().unwrap();
725        let ctx = ApplyContext::new(tmp.path());
726        assert_eq!(
727            ctx.game_path(),
728            tmp.path(),
729            "game_path() must return exactly the path passed to new()"
730        );
731    }
732
733    #[test]
734    fn default_platform_is_win32() {
735        let ctx = ApplyContext::new("/irrelevant");
736        assert_eq!(
737            ctx.platform(),
738            Platform::Win32,
739            "default platform must be Win32"
740        );
741    }
742
743    #[test]
744    fn with_platform_overrides_default() {
745        let ctx = ApplyContext::new("/irrelevant").with_platform(Platform::Ps4);
746        assert_eq!(
747            ctx.platform(),
748            Platform::Ps4,
749            "with_platform must override the Win32 default"
750        );
751    }
752
753    #[test]
754    fn default_ignore_missing_is_false() {
755        let ctx = ApplyContext::new("/irrelevant");
756        assert!(
757            !ctx.ignore_missing(),
758            "ignore_missing must default to false"
759        );
760    }
761
762    #[test]
763    fn with_ignore_missing_toggles_flag_both_ways() {
764        let ctx = ApplyContext::new("/irrelevant").with_ignore_missing(true);
765        assert!(
766            ctx.ignore_missing(),
767            "with_ignore_missing(true) must set the flag"
768        );
769        let ctx = ctx.with_ignore_missing(false);
770        assert!(
771            !ctx.ignore_missing(),
772            "with_ignore_missing(false) must clear the flag"
773        );
774    }
775
776    #[test]
777    fn default_ignore_old_mismatch_is_false() {
778        let ctx = ApplyContext::new("/irrelevant");
779        assert!(
780            !ctx.ignore_old_mismatch(),
781            "ignore_old_mismatch must default to false"
782        );
783    }
784
785    #[test]
786    fn with_ignore_old_mismatch_toggles_flag_both_ways() {
787        let ctx = ApplyContext::new("/irrelevant").with_ignore_old_mismatch(true);
788        assert!(
789            ctx.ignore_old_mismatch(),
790            "with_ignore_old_mismatch(true) must set the flag"
791        );
792        let ctx = ctx.with_ignore_old_mismatch(false);
793        assert!(
794            !ctx.ignore_old_mismatch(),
795            "with_ignore_old_mismatch(false) must clear the flag"
796        );
797    }
798
799    // --- with_observer ---
800    //
801    // The end-to-end "default context uses NoopObserver" check lives in
802    // `src/lib.rs` as `default_no_observer_apply_succeeds_as_before`, which
803    // exercises the full `apply_to` driver path; duplicating it here would
804    // only re-test the same behaviour through a slightly different lens.
805
806    // --- BufWriter cache ---
807
808    #[test]
809    fn buffered_writes_are_invisible_before_flush() {
810        // The whole point of wrapping cached handles in a BufWriter is to
811        // hold small writes in user-space memory until enough have queued
812        // up. Lock that down: a 1-byte write must not be visible on disk
813        // until flush() is called.
814        use std::io::Write;
815
816        let tmp = tempfile::tempdir().unwrap();
817        let mut ctx = ApplyContext::new(tmp.path());
818        let path = tmp.path().join("buffered.dat");
819
820        let writer = ctx.open_cached(path.clone()).unwrap();
821        writer.write_all(&[0xAB]).unwrap();
822
823        // File exists (open_cached opened it with create=true) but is empty
824        // — the byte is sitting in the BufWriter, not on disk.
825        assert_eq!(
826            std::fs::metadata(&path).unwrap().len(),
827            0,
828            "buffered write must not reach disk before flush"
829        );
830
831        ctx.flush().unwrap();
832        assert_eq!(
833            std::fs::read(&path).unwrap(),
834            vec![0xAB],
835            "flush must drain the buffer to disk"
836        );
837    }
838
839    #[test]
840    fn flush_keeps_handles_in_cache() {
841        // flush() is a durability checkpoint, not an eviction — handles
842        // must survive so subsequent chunks targeting the same file reuse
843        // them rather than reopening.
844        let tmp = tempfile::tempdir().unwrap();
845        let mut ctx = ApplyContext::new(tmp.path());
846        ctx.open_cached(tmp.path().join("a.dat")).unwrap();
847        ctx.open_cached(tmp.path().join("b.dat")).unwrap();
848        assert_eq!(ctx.file_cache.len(), 2);
849
850        ctx.flush().unwrap();
851        assert_eq!(
852            ctx.file_cache.len(),
853            2,
854            "flush must not drop cached handles"
855        );
856    }
857
858    #[test]
859    fn evict_cached_flushes_pending_writes_to_disk() {
860        // evict_cached must flush before dropping — otherwise buffered
861        // writes against the about-to-be-closed handle would be silently
862        // discarded by BufWriter::drop's error-swallowing flush.
863        use std::io::Write;
864
865        let tmp = tempfile::tempdir().unwrap();
866        let mut ctx = ApplyContext::new(tmp.path());
867        let path = tmp.path().join("evict.dat");
868
869        let writer = ctx.open_cached(path.clone()).unwrap();
870        writer.write_all(b"queued").unwrap();
871        assert_eq!(
872            std::fs::metadata(&path).unwrap().len(),
873            0,
874            "pre-condition: write is buffered, not on disk"
875        );
876
877        ctx.evict_cached(&path).unwrap();
878        assert_eq!(
879            std::fs::read(&path).unwrap(),
880            b"queued",
881            "evict_cached must flush before closing the handle"
882        );
883        assert!(
884            !ctx.file_cache.contains_key(&path),
885            "evict_cached must also remove the entry"
886        );
887    }
888
889    #[test]
890    fn clear_file_cache_flushes_every_pending_write() {
891        // clear_file_cache must flush every buffered writer before dropping
892        // — RemoveAll relies on this to avoid losing pending data against
893        // the about-to-be-unlinked files.
894        use std::io::Write;
895
896        let tmp = tempfile::tempdir().unwrap();
897        let mut ctx = ApplyContext::new(tmp.path());
898        let a = tmp.path().join("a.dat");
899        let b = tmp.path().join("b.dat");
900
901        ctx.open_cached(a.clone())
902            .unwrap()
903            .write_all(b"AA")
904            .unwrap();
905        ctx.open_cached(b.clone())
906            .unwrap()
907            .write_all(b"BB")
908            .unwrap();
909
910        ctx.clear_file_cache().unwrap();
911
912        assert_eq!(std::fs::read(&a).unwrap(), b"AA");
913        assert_eq!(std::fs::read(&b).unwrap(), b"BB");
914        assert!(ctx.file_cache.is_empty(), "cache must be empty after clear");
915    }
916
917    // --- Debug impl ---
918
919    #[test]
920    fn apply_context_debug_renders_all_fields() {
921        // ApplyContext can't derive Debug because Box<dyn ApplyObserver> doesn't
922        // implement it; the hand-written impl substitutes a placeholder for the
923        // observer. Lock down the rendered shape so future refactors don't
924        // accidentally drop fields (and so the impl itself is exercised by tests).
925        let tmp = tempfile::tempdir().unwrap();
926        let ctx = ApplyContext::new(tmp.path())
927            .with_platform(Platform::Ps4)
928            .with_ignore_missing(true);
929
930        let rendered = format!("{ctx:?}");
931        for needle in [
932            "ApplyContext",
933            "game_path",
934            "platform",
935            "Ps4",
936            "ignore_missing",
937            "true",
938            "ignore_old_mismatch",
939            "file_cache_len",
940            "observer",
941            "<dyn ApplyObserver>",
942        ] {
943            assert!(
944                rendered.contains(needle),
945                "Debug output must mention {needle:?}; got: {rendered}"
946            );
947        }
948    }
949
950    // --- DeleteDirectory happy path ---
951
952    #[test]
953    fn delete_directory_success_removes_existing_dir() {
954        // Exercises the Ok(()) trace+return arm of DeleteDirectory::apply
955        // (previously only the ignore_missing and propagate-error arms were
956        // covered).
957        let tmp = tempfile::tempdir().unwrap();
958        let target = tmp.path().join("to_remove");
959        std::fs::create_dir(&target).unwrap();
960        assert!(target.is_dir(), "pre-condition: directory must exist");
961
962        let mut ctx = ApplyContext::new(tmp.path());
963        DeleteDirectory {
964            name: "to_remove".into(),
965        }
966        .apply(&mut ctx)
967        .expect("delete on an existing directory must succeed");
968
969        assert!(!target.exists(), "directory must be removed");
970    }
971}