zipatch-rs 1.1.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_to`](crate::ZiPatchReader::apply_to) 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 vs 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.
//!
//! For ad-hoc callers that only care about per-chunk events and never need
//! cancellation, a blanket impl is provided for any
//! `FnMut(ChunkEvent) -> ControlFlow<(), ()>` closure — pass the closure
//! directly to [`ApplyContext::with_observer`](crate::ApplyContext::with_observer).
//! Cancellation polling falls back to the trait default ("never cancel") in
//! that mode.
//!
//! # Default observer
//!
//! [`ApplyContext`](crate::ApplyContext) defaults to a no-op observer when
//! none is configured. Parsing-only consumers
//! ([`ZiPatchReader`](crate::ZiPatchReader) without
//! [`apply_to`](crate::ZiPatchReader::apply_to)) pay nothing — the observer
//! is exclusively an apply-layer concept.
//!
//! [`SqpkFile`]: crate::chunk::sqpk::SqpkFile
//! [`mpsc::Sender`]: std::sync::mpsc::Sender

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. 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. `*b"SQPK"`, `*b"ADIR"`).
    ///
    /// `EOF_` will never appear here because the reader consumes that chunk
    /// internally. New tags introduced by Square Enix would surface as
    /// [`ZiPatchError::UnknownChunkTag`](crate::ZiPatchError::UnknownChunkTag)
    /// before any event fires for them.
    pub kind: [u8; 4],
    /// 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: [u8; 4], 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 field type is
    /// `[u8; 4]`, 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;
    /// let ev = ChunkEvent::new(0, *b"SQPK", 96);
    /// assert_eq!(ev.kind_str(), Some("SQPK"));
    ///
    /// let bad = ChunkEvent::new(0, [0xFF, 0xFE, 0, 0], 0);
    /// assert_eq!(bad.kind_str(), None);
    /// ```
    #[must_use]
    pub fn kind_str(&self) -> Option<&str> {
        std::str::from_utf8(&self.kind).ok()
    }
}

/// Hook trait for observing apply-time progress and signalling cancellation.
///
/// See the [module-level docs](self) for the design rationale. 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_to`](crate::ZiPatchReader::apply_to) call. There is no
/// internal synchronisation: implementors that need to forward events to
/// another thread should do so via channels they own.
///
/// # Example
///
/// ```no_run
/// use std::ops::ControlFlow;
/// use zipatch_rs::{ApplyContext, ApplyObserver, ChunkEvent, ZiPatchReader};
///
/// 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 = ApplyContext::new("/opt/ffxiv/game")
///     .with_observer(Progress { total: 12_345_678, applied: 0 });
/// ZiPatchReader::from_path("patch.patch")
///     .unwrap()
///     .apply_to(&mut ctx)
///     .unwrap();
/// ```
pub trait ApplyObserver {
    /// Called after each top-level chunk has been applied successfully.
    ///
    /// Returning [`ControlFlow::Break`] aborts the apply loop immediately;
    /// the apply call returns [`ZiPatchError::Cancelled`].
    ///
    /// Not invoked when the chunk's apply itself fails — the error
    /// propagates from [`apply_to`](crate::ZiPatchReader::apply_to) without
    /// firing this method. The event is therefore a "chunk succeeded"
    /// signal, not a "chunk attempted" one.
    ///
    /// The default implementation does nothing and continues.
    ///
    /// [`ZiPatchError::Cancelled`]: crate::ZiPatchError::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 [`ZiPatchError::Cancelled`].
    ///
    /// The default implementation always returns `false`.
    ///
    /// [`ZiPatchError::Cancelled`]: crate::ZiPatchError::Cancelled
    fn should_cancel(&mut self) -> bool {
        false
    }
}

/// Blanket impl: any `FnMut(ChunkEvent) -> ControlFlow<(), ()>` closure is
/// an [`ApplyObserver`] that only forwards per-chunk events.
///
/// Cancellation falls back to the trait default (always `false`). For
/// cancellation support, implement [`ApplyObserver`] directly on a struct
/// that owns whatever cancellation handle you use
/// (e.g. an [`AtomicBool`](std::sync::atomic::AtomicBool)).
///
/// # Example
///
/// ```no_run
/// use std::ops::ControlFlow;
/// use zipatch_rs::{ApplyContext, ChunkEvent, ZiPatchReader};
///
/// let mut ctx = ApplyContext::new("/opt/ffxiv/game")
///     .with_observer(|ev: ChunkEvent| {
///         println!("applied chunk {} ({} bytes total)", ev.index, ev.bytes_read);
///         ControlFlow::Continue(())
///     });
/// ZiPatchReader::from_path("patch.patch")
///     .unwrap()
///     .apply_to(&mut ctx)
///     .unwrap();
/// ```
impl<F> ApplyObserver for F
where
    F: FnMut(ChunkEvent) -> ControlFlow<(), ()>,
{
    fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
        self(ev)
    }
}

/// No-op observer used by [`ApplyContext`](crate::ApplyContext) when none
/// is configured.
///
/// Public because [`ApplyContext::with_observer`](crate::ApplyContext::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::*;

    // --- NoopObserver ---

    #[test]
    fn noop_observer_on_chunk_applied_always_continues() {
        let mut obs = NoopObserver;
        let ev = ChunkEvent::new(0, *b"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"
            );
        }
    }

    // --- Closure blanket impl ---

    #[test]
    fn closure_observer_accumulates_events_via_closure_mutation() {
        // Verify that the closure can mutate captured state and that events
        // arrive in the order they are fired with correct field values.
        let mut seen: Vec<ChunkEvent> = Vec::new();
        {
            let mut obs = |ev| {
                seen.push(ev);
                ControlFlow::Continue(())
            };
            assert_eq!(
                obs.on_chunk_applied(ChunkEvent::new(0, *b"ADIR", 32)),
                ControlFlow::Continue(()),
                "first call must continue"
            );
            assert_eq!(
                obs.on_chunk_applied(ChunkEvent::new(1, *b"SQPK", 96)),
                ControlFlow::Continue(()),
                "second call must continue"
            );
        }
        assert_eq!(seen.len(), 2, "exactly two events must have been captured");
        assert_eq!(seen[0].index, 0);
        assert_eq!(seen[0].kind, *b"ADIR");
        assert_eq!(seen[0].bytes_read, 32);
        assert_eq!(seen[1].index, 1);
        assert_eq!(seen[1].kind, *b"SQPK");
        assert_eq!(seen[1].bytes_read, 96);
    }

    #[test]
    fn closure_observer_break_propagates_to_caller() {
        // A closure that immediately returns Break must cause on_chunk_applied
        // to return Break — the blanket impl must not swallow it.
        let mut obs = |_| ControlFlow::Break(());
        assert_eq!(
            obs.on_chunk_applied(ChunkEvent::new(0, *b"SQPK", 16)),
            ControlFlow::Break(()),
            "Break from closure must propagate through the blanket impl"
        );
    }

    #[test]
    fn closure_observer_break_after_n_events() {
        // Observer that continues for the first two events then breaks — verify
        // it returns the exact control flow value at each call.
        let mut count = 0usize;
        let mut obs = |_| {
            count += 1;
            if count < 3 {
                ControlFlow::Continue(())
            } else {
                ControlFlow::Break(())
            }
        };
        assert_eq!(
            obs.on_chunk_applied(ChunkEvent::new(0, *b"ADIR", 10)),
            ControlFlow::Continue(())
        );
        assert_eq!(
            obs.on_chunk_applied(ChunkEvent::new(1, *b"ADIR", 20)),
            ControlFlow::Continue(())
        );
        assert_eq!(
            obs.on_chunk_applied(ChunkEvent::new(2, *b"SQPK", 30)),
            ControlFlow::Break(()),
            "third call must break"
        );
    }

    #[test]
    fn closure_observer_should_cancel_always_false() {
        // The blanket impl has no cancellation mechanism; should_cancel must
        // consistently return false regardless of how many events have fired.
        let mut obs = |_| ControlFlow::Continue(());
        for _ in 0..3 {
            assert!(
                !obs.should_cancel(),
                "closure observer must never cancel mid-chunk"
            );
        }
    }

    // --- ChunkEvent ---

    #[test]
    fn chunk_event_new_stores_all_fields_exactly() {
        let ev = ChunkEvent::new(7, *b"SQPK", 1024);
        assert_eq!(ev.index, 7, "index field mismatch");
        assert_eq!(ev.kind, *b"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, *b"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, *b"ADIR", 0);
        assert_eq!(ev.kind_str(), Some("ADIR"));
        let ev = ChunkEvent::new(0, *b"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, [0xFF, 0xFE, 0xFD, 0xFC], 0);
        assert_eq!(ev.kind_str(), None, "non-UTF-8 tag bytes must produce None");
    }
}