Skip to main content

zipatch_rs/apply/
observer.rs

1//! Progress and cancellation hooks for the apply layer.
2//!
3//! [`ApplyObserver`] is the user-visible instrumentation surface for a long-
4//! running [`apply_to`](crate::ZiPatchReader::apply_to) call. It is designed
5//! around two concrete needs from the downstream `gaveloc-patcher` consumer:
6//!
7//! - **Smooth UI progress** — a desktop launcher needs to drive a progress
8//!   bar while applying multi-GB FFXIV patches. Per-chunk events plus a
9//!   running byte counter are enough to compute "X of Y bytes applied"
10//!   without ever buffering the full patch in memory.
11//! - **User-initiated cancellation** — a single [`SqpkFile`] `AddFile`
12//!   chunk can carry hundreds of megabytes of DEFLATE blocks. Chunk-
13//!   boundary checks are not enough; cancellation must be observable
14//!   *inside* the long-running block loop. The cheap
15//!   [`ApplyObserver::should_cancel`] predicate is polled between blocks.
16//!
17//! # Trait vs closure
18//!
19//! The observer is a **trait** rather than a closure so that callers can
20//! own state across event methods (e.g. a UI handle, an [`mpsc::Sender`],
21//! a cancellation token) without juggling shared captures. Every method has
22//! a no-op default, so implementors only override what they care about.
23//!
24//! For ad-hoc callers that only care about per-chunk events and never need
25//! cancellation, a blanket impl is provided for any
26//! `FnMut(ChunkEvent) -> ControlFlow<(), ()>` closure — pass the closure
27//! directly to [`ApplyContext::with_observer`](crate::ApplyContext::with_observer).
28//! Cancellation polling falls back to the trait default ("never cancel") in
29//! that mode.
30//!
31//! # Default observer
32//!
33//! [`ApplyContext`](crate::ApplyContext) defaults to a no-op observer when
34//! none is configured. Parsing-only consumers
35//! ([`ZiPatchReader`](crate::ZiPatchReader) without
36//! [`apply_to`](crate::ZiPatchReader::apply_to)) pay nothing — the observer
37//! is exclusively an apply-layer concept.
38//!
39//! [`SqpkFile`]: crate::chunk::sqpk::SqpkFile
40//! [`mpsc::Sender`]: std::sync::mpsc::Sender
41
42use std::ops::ControlFlow;
43
44/// One chunk-applied event delivered to an [`ApplyObserver`].
45///
46/// Fired after each top-level chunk's apply has completed successfully. The
47/// `index` field is the zero-based ordinal of the chunk in the patch stream,
48/// counting every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader) (the
49/// internal `EOF_` terminator is not yielded and is not counted). The `kind`
50/// is the 4-byte ASCII wire tag of the chunk, which lets the consumer
51/// categorise events without needing to match on the [`Chunk`](crate::Chunk)
52/// enum directly. `bytes_read` is the running total of bytes consumed from
53/// the patch stream up to and including this chunk's frame (length prefix,
54/// tag, body, and CRC32).
55///
56/// # Stability
57///
58/// The struct is `#[non_exhaustive]`. New fields may be added in future minor
59/// versions. Construct in tests via the [`ChunkEvent::new`] associated
60/// function rather than struct-literal syntax.
61#[non_exhaustive]
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct ChunkEvent {
64    /// Zero-based index of the chunk within the patch stream.
65    ///
66    /// Counts every chunk yielded by [`ZiPatchReader`](crate::ZiPatchReader),
67    /// in stream order. The internal `EOF_` terminator is not counted.
68    pub index: usize,
69    /// 4-byte ASCII wire tag of the chunk (e.g. `*b"SQPK"`, `*b"ADIR"`).
70    ///
71    /// `EOF_` will never appear here because the reader consumes that chunk
72    /// internally. New tags introduced by Square Enix would surface as
73    /// [`ZiPatchError::UnknownChunkTag`](crate::ZiPatchError::UnknownChunkTag)
74    /// before any event fires for them.
75    pub kind: [u8; 4],
76    /// Running total of bytes consumed from the patch stream, measured from
77    /// the start of the patch file (including the 12-byte magic prefix).
78    ///
79    /// Monotonically non-decreasing across successive events; equal to the
80    /// stream position immediately after the chunk's CRC32 was read. Useful
81    /// for driving a `bytes_applied / total_patch_size` progress bar.
82    pub bytes_read: u64,
83}
84
85impl ChunkEvent {
86    /// Construct a [`ChunkEvent`] from its component fields.
87    ///
88    /// Primarily intended for unit-test fixtures. Production code receives
89    /// events from the apply driver and does not need to construct them.
90    #[must_use]
91    pub fn new(index: usize, kind: [u8; 4], bytes_read: u64) -> Self {
92        Self {
93            index,
94            kind,
95            bytes_read,
96        }
97    }
98
99    /// Returns the chunk's [`kind`](Self::kind) tag as a `&str` if it is
100    /// valid UTF-8.
101    ///
102    /// All wire tags defined by the `ZiPatch` format are 4-byte ASCII
103    /// (`SQPK`, `ADIR`, `APLY`, …), so this returns `Some(&str)` in
104    /// practice. The fallible variant exists because the field type is
105    /// `[u8; 4]`, which by itself does not constrain the contents to UTF-8 —
106    /// a forward-compatible future tag or a corrupt event constructed in
107    /// tests could in principle carry non-ASCII bytes.
108    ///
109    /// # Example
110    ///
111    /// ```
112    /// use zipatch_rs::ChunkEvent;
113    /// let ev = ChunkEvent::new(0, *b"SQPK", 96);
114    /// assert_eq!(ev.kind_str(), Some("SQPK"));
115    ///
116    /// let bad = ChunkEvent::new(0, [0xFF, 0xFE, 0, 0], 0);
117    /// assert_eq!(bad.kind_str(), None);
118    /// ```
119    #[must_use]
120    pub fn kind_str(&self) -> Option<&str> {
121        std::str::from_utf8(&self.kind).ok()
122    }
123}
124
125/// Hook trait for observing apply-time progress and signalling cancellation.
126///
127/// All methods
128/// have no-op defaults so implementors override only what they need.
129///
130/// # Threading
131///
132/// An observer is borrowed mutably by the apply driver for the lifetime of
133/// the [`apply_to`](crate::ZiPatchReader::apply_to) call. There is no
134/// internal synchronisation: implementors that need to forward events to
135/// another thread should do so via channels they own.
136///
137/// # Example
138///
139/// ```no_run
140/// use std::ops::ControlFlow;
141/// use zipatch_rs::{ApplyContext, ApplyObserver, ChunkEvent, ZiPatchReader};
142///
143/// struct Progress {
144///     total: u64,
145///     applied: u64,
146/// }
147///
148/// impl ApplyObserver for Progress {
149///     fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
150///         self.applied = ev.bytes_read;
151///         println!("progress: {}/{}", self.applied, self.total);
152///         ControlFlow::Continue(())
153///     }
154/// }
155///
156/// let mut ctx = ApplyContext::new("/opt/ffxiv/game")
157///     .with_observer(Progress { total: 12_345_678, applied: 0 });
158/// ZiPatchReader::from_path("patch.patch")
159///     .unwrap()
160///     .apply_to(&mut ctx)
161///     .unwrap();
162/// ```
163pub trait ApplyObserver {
164    /// Called after each top-level chunk has been applied successfully.
165    ///
166    /// Returning [`ControlFlow::Break`] aborts the apply loop immediately;
167    /// the apply call returns [`ZiPatchError::Cancelled`].
168    ///
169    /// Not invoked when the chunk's apply itself fails — the error
170    /// propagates from [`apply_to`](crate::ZiPatchReader::apply_to) without
171    /// firing this method. The event is therefore a "chunk succeeded"
172    /// signal, not a "chunk attempted" one.
173    ///
174    /// The default implementation does nothing and continues.
175    ///
176    /// [`ZiPatchError::Cancelled`]: crate::ZiPatchError::Cancelled
177    fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
178        let _ = ev;
179        ControlFlow::Continue(())
180    }
181
182    /// Polled inside long-running chunks to check for user cancellation.
183    ///
184    /// Implementors should make this method **cheap** — it is called once
185    /// per block inside the
186    /// [`SqpkFile`](crate::chunk::sqpk::SqpkFile) `AddFile` loop and on
187    /// every iteration of any future fine-grained loop the apply layer adds.
188    /// A simple atomic-bool load is the recommended implementation.
189    ///
190    /// Polled before each block within long-running chunks (currently only
191    /// `SQPK F` `AddFile`). Once a block's I/O has started, it completes —
192    /// cancellation takes effect at the next block boundary, not mid-write.
193    /// This means the last block of any chunk always finishes once started.
194    ///
195    /// Returning `true` causes the current apply operation to abort at the
196    /// next checkpoint with [`ZiPatchError::Cancelled`].
197    ///
198    /// The default implementation always returns `false`.
199    ///
200    /// [`ZiPatchError::Cancelled`]: crate::ZiPatchError::Cancelled
201    fn should_cancel(&mut self) -> bool {
202        false
203    }
204}
205
206/// Blanket impl: any `FnMut(ChunkEvent) -> ControlFlow<(), ()>` closure is
207/// an [`ApplyObserver`] that only forwards per-chunk events.
208///
209/// Cancellation falls back to the trait default (always `false`). For
210/// cancellation support, implement [`ApplyObserver`] directly on a struct
211/// that owns whatever cancellation handle you use
212/// (e.g. an [`AtomicBool`](std::sync::atomic::AtomicBool)).
213///
214/// # Example
215///
216/// ```no_run
217/// use std::ops::ControlFlow;
218/// use zipatch_rs::{ApplyContext, ChunkEvent, ZiPatchReader};
219///
220/// let mut ctx = ApplyContext::new("/opt/ffxiv/game")
221///     .with_observer(|ev: ChunkEvent| {
222///         println!("applied chunk {} ({} bytes total)", ev.index, ev.bytes_read);
223///         ControlFlow::Continue(())
224///     });
225/// ZiPatchReader::from_path("patch.patch")
226///     .unwrap()
227///     .apply_to(&mut ctx)
228///     .unwrap();
229/// ```
230impl<F> ApplyObserver for F
231where
232    F: FnMut(ChunkEvent) -> ControlFlow<(), ()>,
233{
234    fn on_chunk_applied(&mut self, ev: ChunkEvent) -> ControlFlow<(), ()> {
235        self(ev)
236    }
237}
238
239/// No-op observer used by [`ApplyContext`](crate::ApplyContext) when none
240/// is configured.
241///
242/// Public because [`ApplyContext::with_observer`](crate::ApplyContext::with_observer)
243/// is generic over `impl ApplyObserver + 'static` and callers occasionally need
244/// to name the default in `Box<dyn ApplyObserver>`-typed fields. All trait
245/// methods use the trait defaults.
246#[derive(Debug, Default, Clone, Copy)]
247pub struct NoopObserver;
248
249impl ApplyObserver for NoopObserver {}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    // --- NoopObserver ---
256
257    #[test]
258    fn noop_observer_on_chunk_applied_always_continues() {
259        let mut obs = NoopObserver;
260        let ev = ChunkEvent::new(0, *b"ADIR", 32);
261        assert_eq!(
262            obs.on_chunk_applied(ev),
263            ControlFlow::Continue(()),
264            "NoopObserver must never break"
265        );
266    }
267
268    #[test]
269    fn noop_observer_should_cancel_always_false() {
270        let mut obs = NoopObserver;
271        // Call multiple times; must stay false — never flip to true.
272        for _ in 0..5 {
273            assert!(
274                !obs.should_cancel(),
275                "NoopObserver must never request cancellation"
276            );
277        }
278    }
279
280    // --- Closure blanket impl ---
281
282    #[test]
283    fn closure_observer_accumulates_events_via_closure_mutation() {
284        // Verify that the closure can mutate captured state and that events
285        // arrive in the order they are fired with correct field values.
286        let mut seen: Vec<ChunkEvent> = Vec::new();
287        {
288            let mut obs = |ev| {
289                seen.push(ev);
290                ControlFlow::Continue(())
291            };
292            assert_eq!(
293                obs.on_chunk_applied(ChunkEvent::new(0, *b"ADIR", 32)),
294                ControlFlow::Continue(()),
295                "first call must continue"
296            );
297            assert_eq!(
298                obs.on_chunk_applied(ChunkEvent::new(1, *b"SQPK", 96)),
299                ControlFlow::Continue(()),
300                "second call must continue"
301            );
302        }
303        assert_eq!(seen.len(), 2, "exactly two events must have been captured");
304        assert_eq!(seen[0].index, 0);
305        assert_eq!(seen[0].kind, *b"ADIR");
306        assert_eq!(seen[0].bytes_read, 32);
307        assert_eq!(seen[1].index, 1);
308        assert_eq!(seen[1].kind, *b"SQPK");
309        assert_eq!(seen[1].bytes_read, 96);
310    }
311
312    #[test]
313    fn closure_observer_break_propagates_to_caller() {
314        // A closure that immediately returns Break must cause on_chunk_applied
315        // to return Break — the blanket impl must not swallow it.
316        let mut obs = |_| ControlFlow::Break(());
317        assert_eq!(
318            obs.on_chunk_applied(ChunkEvent::new(0, *b"SQPK", 16)),
319            ControlFlow::Break(()),
320            "Break from closure must propagate through the blanket impl"
321        );
322    }
323
324    #[test]
325    fn closure_observer_break_after_n_events() {
326        // Observer that continues for the first two events then breaks — verify
327        // it returns the exact control flow value at each call.
328        let mut count = 0usize;
329        let mut obs = |_| {
330            count += 1;
331            if count < 3 {
332                ControlFlow::Continue(())
333            } else {
334                ControlFlow::Break(())
335            }
336        };
337        assert_eq!(
338            obs.on_chunk_applied(ChunkEvent::new(0, *b"ADIR", 10)),
339            ControlFlow::Continue(())
340        );
341        assert_eq!(
342            obs.on_chunk_applied(ChunkEvent::new(1, *b"ADIR", 20)),
343            ControlFlow::Continue(())
344        );
345        assert_eq!(
346            obs.on_chunk_applied(ChunkEvent::new(2, *b"SQPK", 30)),
347            ControlFlow::Break(()),
348            "third call must break"
349        );
350    }
351
352    #[test]
353    fn closure_observer_should_cancel_always_false() {
354        // The blanket impl has no cancellation mechanism; should_cancel must
355        // consistently return false regardless of how many events have fired.
356        let mut obs = |_| ControlFlow::Continue(());
357        for _ in 0..3 {
358            assert!(
359                !obs.should_cancel(),
360                "closure observer must never cancel mid-chunk"
361            );
362        }
363    }
364
365    // --- ChunkEvent ---
366
367    #[test]
368    fn chunk_event_new_stores_all_fields_exactly() {
369        let ev = ChunkEvent::new(7, *b"SQPK", 1024);
370        assert_eq!(ev.index, 7, "index field mismatch");
371        assert_eq!(ev.kind, *b"SQPK", "kind field mismatch");
372        assert_eq!(ev.bytes_read, 1024, "bytes_read field mismatch");
373    }
374
375    #[test]
376    fn chunk_event_clone_and_eq_are_consistent() {
377        let ev = ChunkEvent::new(3, *b"ADIR", 512);
378        let cloned = ev;
379        assert_eq!(
380            ev, cloned,
381            "ChunkEvent must be Copy/Eq with field-wise equality"
382        );
383    }
384
385    #[test]
386    fn chunk_event_kind_str_returns_some_for_ascii_tag() {
387        let ev = ChunkEvent::new(0, *b"ADIR", 0);
388        assert_eq!(ev.kind_str(), Some("ADIR"));
389        let ev = ChunkEvent::new(0, *b"SQPK", 0);
390        assert_eq!(ev.kind_str(), Some("SQPK"));
391    }
392
393    #[test]
394    fn chunk_event_kind_str_returns_none_for_invalid_utf8() {
395        let ev = ChunkEvent::new(0, [0xFF, 0xFE, 0xFD, 0xFC], 0);
396        assert_eq!(ev.kind_str(), None, "non-UTF-8 tag bytes must produce None");
397    }
398}