zipatch-rs 1.7.0

Parser for FFXIV ZiPatch patch files
Documentation
//! Progress and cancellation hooks for the apply layer.
//!
//! [`ApplyObserver`] is the user-visible instrumentation surface for a long-
//! running [`apply_patch`](crate::ApplyConfig::apply_patch) call. It is designed
//! around two concrete needs from the downstream `gaveloc-patcher` consumer:
//!
//! - **Smooth UI progress** — a desktop launcher needs to drive a progress
//!   bar while applying multi-GB FFXIV patches. Per-chunk events plus a
//!   running byte counter are enough to compute "X of Y bytes applied"
//!   without ever buffering the full patch in memory.
//! - **User-initiated cancellation** — a single [`SqpkFile`] `AddFile`
//!   chunk can carry hundreds of megabytes of DEFLATE blocks. Chunk-
//!   boundary checks are not enough; cancellation must be observable
//!   *inside* the long-running block loop. The cheap
//!   [`ApplyObserver::should_cancel`] predicate is polled between blocks.
//!
//! # Trait, not closure
//!
//! The observer is a **trait** rather than a closure so that callers can
//! own state across event methods (e.g. a UI handle, an [`mpsc::Sender`],
//! a cancellation token) without juggling shared captures. Every method has
//! a no-op default, so implementors only override what they care about.
//!
//! There is **no** blanket impl for closures. The trait carries
//! [`ApplyObserver::should_cancel`] alongside
//! [`ApplyObserver::on_chunk_applied`], and a closure can only carry one of
//! the two — silently filling in the "never cancel" default would disable
//! the cancellation path the consumer almost certainly wants. Implementors
//! pass a struct (typically owning an [`AtomicBool`](std::sync::atomic::AtomicBool)
//! cancellation flag) and override exactly the methods they need.
//!
//! # Default observer
//!
//! [`ApplyConfig`](crate::ApplyConfig) defaults to a no-op observer when
//! none is configured. Parsing-only consumers
//! ([`ZiPatchReader`](crate::ZiPatchReader) without
//! [`apply_patch`](crate::ApplyConfig::apply_patch)) pay nothing — the observer
//! is exclusively an apply-layer concept.
//!
//! [`SqpkFile`]: crate::chunk::sqpk::SqpkFile
//! [`mpsc::Sender`]: std::sync::mpsc::Sender

use crate::newtypes::ChunkTag;
use std::ops::ControlFlow;

/// One chunk-applied event delivered to an [`ApplyObserver`].
///
/// Fired after each top-level chunk's apply has completed successfully. The
/// `index` field is the zero-based ordinal of the chunk in the patch stream,
/// counting every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader) (the
/// internal `EOF_` terminator is not yielded and is not counted). The `kind`
/// is the 4-byte ASCII wire tag of the chunk, which lets the consumer
/// categorise events without needing to match on the [`Chunk`](crate::Chunk)
/// enum directly. `bytes_read` is the running total of bytes consumed from
/// the patch stream up to and including this chunk's frame (length prefix,
/// tag, body, and CRC32).
///
/// # Stability
///
/// The struct is `#[non_exhaustive]`. New fields may be added in future minor
/// versions (per-chunk elapsed time, decompressed byte counts, etc.).
/// Construct in tests via the [`ChunkEvent::new`] associated function rather
/// than struct-literal syntax.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkEvent {
    /// Zero-based index of the chunk within the patch stream.
    ///
    /// Counts every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader),
    /// in stream order. The internal `EOF_` terminator is not counted.
    pub index: usize,
    /// 4-byte ASCII wire tag of the chunk (e.g. [`ChunkTag::SQPK`],
    /// [`ChunkTag::ADIR`]).
    ///
    /// `EOF_` will never appear here because the reader consumes that chunk
    /// internally. New tags introduced by Square Enix would surface as
    /// [`ParseError::UnknownChunkTag`](crate::ParseError::UnknownChunkTag)
    /// before any event fires for them.
    pub kind: ChunkTag,
    /// Running total of bytes consumed from the patch stream, measured from
    /// the start of the patch file (including the 12-byte magic prefix).
    ///
    /// Monotonically non-decreasing across successive events; equal to the
    /// stream position immediately after the chunk's CRC32 was read. Useful
    /// for driving a `bytes_applied / total_patch_size` progress bar.
    pub bytes_read: u64,
}

impl ChunkEvent {
    /// Construct a [`ChunkEvent`] from its component fields.
    ///
    /// Primarily intended for unit-test fixtures. Production code receives
    /// events from the apply driver and does not need to construct them.
    #[must_use]
    pub fn new(index: usize, kind: ChunkTag, bytes_read: u64) -> Self {
        Self {
            index,
            kind,
            bytes_read,
        }
    }

    /// Returns the chunk's [`kind`](Self::kind) tag as a `&str` if it is
    /// valid UTF-8.
    ///
    /// All wire tags defined by the `ZiPatch` format are 4-byte ASCII
    /// (`SQPK`, `ADIR`, `APLY`, …), so this returns `Some(&str)` in
    /// practice. The fallible variant exists because the underlying field is
    /// 4 raw bytes, which by itself does not constrain the contents to
    /// UTF-8 — a forward-compatible future tag or a corrupt event constructed
    /// in tests could in principle carry non-ASCII bytes.
    ///
    /// # Example
    ///
    /// ```
    /// use zipatch_rs::ChunkEvent;
    /// use zipatch_rs::newtypes::ChunkTag;
    /// let ev = ChunkEvent::new(0, ChunkTag::SQPK, 96);
    /// assert_eq!(ev.kind_str(), Some("SQPK"));
    ///
    /// let bad = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0, 0]), 0);
    /// assert_eq!(bad.kind_str(), None);
    /// ```
    #[must_use]
    pub fn kind_str(&self) -> Option<&str> {
        self.kind.as_str()
    }
}

/// Hook trait for observing apply-time progress and signalling cancellation.
///
/// All methods
/// have no-op defaults so implementors override only what they need.
///
/// # Threading
///
/// An observer is borrowed mutably by the apply driver for the lifetime of
/// the [`apply_patch`](crate::ApplyConfig::apply_patch) call. There is no
/// internal synchronisation: implementors that need to forward events to
/// another thread should do so via channels they own.
///
/// The trait has `Send + Sync` supertrait bounds so a boxed observer can
/// be constructed on one thread and driven on another — the typical UI
/// pattern is to construct the observer (often around an
/// [`mpsc::Sender`](std::sync::mpsc::Sender) or an
/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag) on the
/// UI thread, hand it to an [`ApplyConfig`](crate::ApplyConfig), and ship
/// the context to an apply worker. `Sync` costs nothing for the realistic
/// implementations (channel senders, atomics, `Arc<Mutex<_>>`) and lets the
/// observer be shared by reference if a downstream consumer ever needs to.
///
/// # Async usage
///
/// Both [`Self::on_chunk_applied`] and [`Self::should_cancel`] run inline
/// with the apply loop and are intentionally synchronous — see the
/// crate-level "Async usage" section for the rationale. The cancellation
/// poll in particular is called once per `SqpkFile` `AddFile` block and
/// must be cheap (an atomic-bool load is the canonical implementation),
/// which makes it a poor fit for `async` even hypothetically.
///
/// Async consumers wrap the whole apply call in
/// `tokio::task::spawn_blocking` and use an
/// [`AtomicBool`](std::sync::atomic::AtomicBool) cancellation flag the
/// async side can flip from a `tokio::select!` arm or a cancellation
/// token. Per-chunk events that need to reach an async UI go through a
/// channel whose `Sender` lives inside [`Self::on_chunk_applied`] and
/// whose `Receiver` is polled from the async task.
///
/// # Example
///
/// ```no_run
/// use std::ops::ControlFlow;
/// use zipatch_rs::{ApplyConfig, ApplyObserver, ChunkEvent, open_patch};
///
/// struct Progress {
///     total: u64,
///     applied: u64,
/// }
///
/// impl ApplyObserver for Progress {
///     fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
///         self.applied = ev.bytes_read;
///         println!("progress: {}/{}", self.applied, self.total);
///         ControlFlow::Continue(())
///     }
/// }
///
/// let mut ctx = ApplyConfig::new("/opt/ffxiv/game")
///     .with_observer(Progress { total: 12_345_678, applied: 0 });
/// let reader = open_patch("patch.patch").unwrap();
/// ctx.apply_patch(reader).unwrap();
/// ```
pub trait ApplyObserver: Send + Sync {
    /// Called after each top-level chunk has been applied successfully.
    ///
    /// Returning [`ControlFlow::Break`] aborts the apply loop immediately;
    /// the apply call returns [`ApplyError::Cancelled`].
    ///
    /// Not invoked when the chunk's apply itself fails — the error
    /// propagates from [`apply_patch`](crate::ApplyConfig::apply_patch) without
    /// firing this method. The event is therefore a "chunk succeeded"
    /// signal, not a "chunk attempted" one.
    ///
    /// The default implementation does nothing and continues.
    ///
    /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
    fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
        let _ = ev;
        ControlFlow::Continue(())
    }

    /// Polled inside long-running chunks to check for user cancellation.
    ///
    /// Implementors should make this method **cheap** — it is called once
    /// per block inside the
    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile` loop and on
    /// every iteration of any future fine-grained loop the apply layer adds.
    /// A simple atomic-bool load is the recommended implementation.
    ///
    /// Polled before each block within long-running chunks (currently only
    /// `SQPK F` `AddFile`). Once a block's I/O has started, it completes —
    /// cancellation takes effect at the next block boundary, not mid-write.
    /// This means the last block of any chunk always finishes once started.
    ///
    /// Returning `true` causes the current apply operation to abort at the
    /// next checkpoint with [`ApplyError::Cancelled`].
    ///
    /// The default implementation always returns `false`.
    ///
    /// [`ApplyError::Cancelled`]: crate::ApplyError::Cancelled
    fn should_cancel(&mut self) -> bool {
        false
    }
}

/// No-op observer used by [`ApplyConfig`](crate::ApplyConfig) when none
/// is configured.
///
/// Public because [`ApplyConfig::with_observer`](crate::ApplyConfig::with_observer)
/// is generic over `impl ApplyObserver + 'static` and callers occasionally need
/// to name the default in `Box<dyn ApplyObserver>`-typed fields. All trait
/// methods use the trait defaults.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopObserver;

impl ApplyObserver for NoopObserver {}

#[cfg(test)]
mod tests {
    use super::*;

    // --- Thread-safety bounds ---

    #[test]
    fn boxed_observer_is_send_and_sync() {
        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
        assert_send_sync::<dyn ApplyObserver>();
        assert_send_sync::<Box<dyn ApplyObserver>>();
        let boxed: Box<dyn ApplyObserver> = Box::new(NoopObserver);
        std::thread::spawn(move || {
            let _ = boxed;
        })
        .join()
        .unwrap();
    }

    // --- NoopObserver ---

    #[test]
    fn noop_observer_on_chunk_applied_always_continues() {
        let mut obs = NoopObserver;
        let ev = ChunkEvent::new(0, ChunkTag::ADIR, 32);
        assert_eq!(
            obs.on_chunk_applied(ev),
            ControlFlow::Continue(()),
            "NoopObserver must never break"
        );
    }

    #[test]
    fn noop_observer_should_cancel_always_false() {
        let mut obs = NoopObserver;
        // Call multiple times; must stay false — never flip to true.
        for _ in 0..5 {
            assert!(
                !obs.should_cancel(),
                "NoopObserver must never request cancellation"
            );
        }
    }

    // --- ChunkEvent ---

    #[test]
    fn chunk_event_new_stores_all_fields_exactly() {
        let ev = ChunkEvent::new(7, ChunkTag::SQPK, 1024);
        assert_eq!(ev.index, 7, "index field mismatch");
        assert_eq!(ev.kind, ChunkTag::SQPK, "kind field mismatch");
        assert_eq!(ev.bytes_read, 1024, "bytes_read field mismatch");
    }

    #[test]
    fn chunk_event_clone_and_eq_are_consistent() {
        let ev = ChunkEvent::new(3, ChunkTag::ADIR, 512);
        let cloned = ev;
        assert_eq!(
            ev, cloned,
            "ChunkEvent must be Copy/Eq with field-wise equality"
        );
    }

    #[test]
    fn chunk_event_kind_str_returns_some_for_ascii_tag() {
        let ev = ChunkEvent::new(0, ChunkTag::ADIR, 0);
        assert_eq!(ev.kind_str(), Some("ADIR"));
        let ev = ChunkEvent::new(0, ChunkTag::SQPK, 0);
        assert_eq!(ev.kind_str(), Some("SQPK"));
    }

    #[test]
    fn chunk_event_kind_str_returns_none_for_invalid_utf8() {
        let ev = ChunkEvent::new(0, ChunkTag::new([0xFF, 0xFE, 0xFD, 0xFC]), 0);
        assert_eq!(ev.kind_str(), None, "non-UTF-8 tag bytes must produce None");
    }
}