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}