zipatch_rs/newtypes.rs
1//! Strongly-typed wrappers around primitive identifiers that appear in the
2//! public API.
3//!
4//! Each newtype here exists to give a domain concept a single canonical Rust
5//! type, instead of leaking the raw primitive (`u32`, `[u8; 4]`, …) at every
6//! call site. The wrappers are `#[repr(transparent)]` so they carry no runtime
7//! cost compared to the underlying primitive.
8//!
9//! - [`PatchIndex`](crate::newtypes::PatchIndex) — a 0-based index into a
10//! multi-patch chain.
11//! - [`ChunkTag`](crate::newtypes::ChunkTag) — a 4-byte ASCII chunk tag
12//! (`FHDR`, `APLY`, `SQPK`, …).
13//! - [`SchemaVersion`](crate::newtypes::SchemaVersion) — a persisted record's
14//! schema-format version.
15
16use std::fmt;
17
18// ---------------------------------------------------------------------------
19// PatchIndex
20// ---------------------------------------------------------------------------
21
22/// Zero-based index of a patch within a multi-patch chain.
23///
24/// Used by [`crate::index::PatchSource::read`] and surfaced in
25/// [`crate::IndexError::PatchIndexOutOfRange`] to identify which patch in the
26/// chain a [`crate::index::Plan`] is referring to. The first patch added to a
27/// [`crate::index::PlanBuilder`] is `PatchIndex(0)`, the second
28/// `PatchIndex(1)`, and so on.
29#[repr(transparent)]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct PatchIndex(u32);
33
34impl PatchIndex {
35 /// Construct a [`PatchIndex`] from a raw `u32`.
36 #[must_use]
37 pub const fn new(idx: u32) -> Self {
38 Self(idx)
39 }
40
41 /// Return the wrapped `u32`.
42 #[must_use]
43 pub const fn get(self) -> u32 {
44 self.0
45 }
46}
47
48impl fmt::Display for PatchIndex {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 self.0.fmt(f)
51 }
52}
53
54impl From<u32> for PatchIndex {
55 fn from(v: u32) -> Self {
56 Self(v)
57 }
58}
59
60impl From<PatchIndex> for u32 {
61 fn from(v: PatchIndex) -> Self {
62 v.0
63 }
64}
65
66// ---------------------------------------------------------------------------
67// ChunkTag
68// ---------------------------------------------------------------------------
69
70/// A 4-byte ASCII chunk tag identifying the wire-format kind of a `ZiPatch`
71/// chunk frame (`FHDR`, `APLY`, `SQPK`, `EOF_`, …).
72///
73/// Construct via [`ChunkTag::new`] / [`ChunkTag::from_bytes`] or one of the
74/// well-known constants (e.g. [`ChunkTag::SQPK`]). Inspect the raw bytes via
75/// [`ChunkTag::as_bytes`].
76///
77/// The [`Display`](std::fmt::Display) impl renders printable ASCII bytes
78/// directly, NUL bytes as `_`, and any other byte as `.` — matching the
79/// `zipatch dump` CLI's chunk-tag rendering.
80#[repr(transparent)]
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct ChunkTag([u8; 4]);
83
84impl ChunkTag {
85 /// `FHDR` — the file-header chunk that opens every `ZiPatch` stream.
86 pub const FHDR: ChunkTag = ChunkTag(*b"FHDR");
87 /// `APLY` — sets/clears a boolean apply-time flag.
88 pub const APLY: ChunkTag = ChunkTag(*b"APLY");
89 /// `APFS` — apply free-space book-keeping (no-op at apply time).
90 pub const APFS: ChunkTag = ChunkTag(*b"APFS");
91 /// `ADIR` — create a directory under the install root.
92 pub const ADIR: ChunkTag = ChunkTag(*b"ADIR");
93 /// `DELD` — delete a directory under the install root.
94 pub const DELD: ChunkTag = ChunkTag(*b"DELD");
95 /// `SQPK` — the SQPK workhorse chunk wrapping one of eight sub-commands.
96 pub const SQPK: ChunkTag = ChunkTag(*b"SQPK");
97 /// `EOF_` — end-of-stream terminator.
98 pub const EOF: ChunkTag = ChunkTag(*b"EOF_");
99
100 /// Construct a [`ChunkTag`] from a 4-byte array.
101 #[must_use]
102 pub const fn new(bytes: [u8; 4]) -> Self {
103 Self(bytes)
104 }
105
106 /// Construct a [`ChunkTag`] from a borrowed 4-byte slice.
107 #[must_use]
108 pub const fn from_bytes(bytes: &[u8; 4]) -> Self {
109 Self(*bytes)
110 }
111
112 /// Return the raw 4 bytes of the tag.
113 #[must_use]
114 pub const fn as_bytes(&self) -> &[u8; 4] {
115 &self.0
116 }
117
118 /// Return the tag as a `&str` if every byte is valid UTF-8.
119 ///
120 /// All wire tags defined by the `ZiPatch` format are 4-byte ASCII, so
121 /// this returns `Some(&str)` in practice; the fallible variant exists
122 /// because the underlying field type does not constrain bytes to UTF-8.
123 #[must_use]
124 pub fn as_str(&self) -> Option<&str> {
125 std::str::from_utf8(&self.0).ok()
126 }
127}
128
129impl fmt::Display for ChunkTag {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 let mut buf = [0u8; 4];
132 for (out, b) in buf.iter_mut().zip(self.0.iter()) {
133 *out = if b.is_ascii_graphic() || *b == b' ' {
134 *b
135 } else if *b == 0 {
136 b'_'
137 } else {
138 b'.'
139 };
140 }
141 // SAFETY: every byte written into `buf` is ASCII (graphic, space, `_`,
142 // or `.`), so the slice is valid UTF-8.
143 f.write_str(std::str::from_utf8(&buf).unwrap_or("????"))
144 }
145}
146
147impl From<[u8; 4]> for ChunkTag {
148 fn from(v: [u8; 4]) -> Self {
149 Self(v)
150 }
151}
152
153impl From<ChunkTag> for [u8; 4] {
154 fn from(v: ChunkTag) -> Self {
155 v.0
156 }
157}
158
159impl PartialEq<[u8; 4]> for ChunkTag {
160 fn eq(&self, other: &[u8; 4]) -> bool {
161 &self.0 == other
162 }
163}
164
165impl PartialEq<ChunkTag> for [u8; 4] {
166 fn eq(&self, other: &ChunkTag) -> bool {
167 self == &other.0
168 }
169}
170
171// ---------------------------------------------------------------------------
172// SchemaVersion
173// ---------------------------------------------------------------------------
174
175/// Schema-format version stamped on a persisted record (e.g. a
176/// [`crate::apply::Checkpoint`] or a [`crate::index::Plan`]).
177///
178/// Strict-equality compatibility: a persisted record with a `SchemaVersion`
179/// that does not equal the build's `CURRENT_SCHEMA_VERSION` is refused rather
180/// than silently re-interpreted. Use [`SchemaVersion::compatible_with`] to
181/// perform the comparison.
182#[repr(transparent)]
183#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct SchemaVersion(u32);
186
187impl SchemaVersion {
188 /// Construct a [`SchemaVersion`] from a raw `u32`.
189 #[must_use]
190 pub const fn new(v: u32) -> Self {
191 Self(v)
192 }
193
194 /// Return the wrapped `u32`.
195 #[must_use]
196 pub const fn get(self) -> u32 {
197 self.0
198 }
199
200 /// Returns `true` if `self` is compatible with `other`.
201 ///
202 /// Compatibility is strict equality: persisted records carrying a
203 /// different version cannot be silently consumed against the current
204 /// schema, because the layout may have shifted in either direction.
205 #[must_use]
206 pub const fn compatible_with(self, other: Self) -> bool {
207 self.0 == other.0
208 }
209
210 /// Return a [`SchemaVersion`] whose inner `u32` is
211 /// `self.get().wrapping_add(rhs)`. Intended for tests that need to
212 /// fabricate a "version off by one" value.
213 #[must_use]
214 pub const fn wrapping_add(self, rhs: u32) -> Self {
215 Self(self.0.wrapping_add(rhs))
216 }
217
218 /// Return a [`SchemaVersion`] whose inner `u32` is
219 /// `self.get().wrapping_sub(rhs)`. Intended for tests that need to
220 /// fabricate a "version off by one" value.
221 #[must_use]
222 pub const fn wrapping_sub(self, rhs: u32) -> Self {
223 Self(self.0.wrapping_sub(rhs))
224 }
225}
226
227impl fmt::Display for SchemaVersion {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 self.0.fmt(f)
230 }
231}
232
233impl From<u32> for SchemaVersion {
234 fn from(v: u32) -> Self {
235 Self(v)
236 }
237}
238
239impl From<SchemaVersion> for u32 {
240 fn from(v: SchemaVersion) -> Self {
241 v.0
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn patch_index_round_trip() {
251 let p = PatchIndex::new(7);
252 assert_eq!(p.get(), 7);
253 assert_eq!(u32::from(p), 7);
254 assert_eq!(PatchIndex::from(7u32), p);
255 }
256
257 #[test]
258 fn chunk_tag_display_printable_ascii() {
259 assert_eq!(format!("{}", ChunkTag::SQPK), "SQPK");
260 assert_eq!(format!("{}", ChunkTag::EOF), "EOF_");
261 assert_eq!(format!("{}", ChunkTag::new([0, b'A', b'B', b'C'])), "_ABC");
262 assert_eq!(
263 format!("{}", ChunkTag::new([0xff, b'A', b'B', b'C'])),
264 ".ABC"
265 );
266 }
267
268 #[test]
269 fn chunk_tag_constants_match_ascii() {
270 assert_eq!(ChunkTag::FHDR.as_bytes(), b"FHDR");
271 assert_eq!(ChunkTag::SQPK.as_bytes(), b"SQPK");
272 assert_eq!(ChunkTag::EOF.as_bytes(), b"EOF_");
273 }
274
275 #[test]
276 fn chunk_tag_eq_array() {
277 let tag = ChunkTag::SQPK;
278 assert!(tag == *b"SQPK");
279 assert!(*b"SQPK" == tag);
280 }
281
282 #[test]
283 fn schema_version_compat_is_strict_equality() {
284 let a = SchemaVersion::new(1);
285 let b = SchemaVersion::new(1);
286 let c = SchemaVersion::new(2);
287 assert!(a.compatible_with(b));
288 assert!(!a.compatible_with(c));
289 }
290}