Skip to main content

sqlite_diff_rs/
parser.rs

1//! Parser for `SQLite` changeset/patchset binary format.
2//!
3//! Parses `SQLite` session extension changesets and patchsets from binary into
4//! [`DiffSetBuilder`] instances.
5//!
6//! # Binary Format
7//!
8//! The format consists of one or more table sections:
9//!
10//! ```text
11//! Table Header:
12//! ├── Marker: 'T' (0x54) for changeset, 'P' (0x50) for patchset
13//! ├── Column count (1 byte)
14//! ├── PK flags (1 byte per column: 0x01 = PK, 0x00 = not)
15//! └── Table name (null-terminated UTF-8)
16//!
17//! Change Records (repeated):
18//! ├── Operation code: INSERT=0x12, DELETE=0x09, UPDATE=0x17
19//! ├── Indirect flag (1 byte, usually 0)
20//! └── Values (encoded per operation type)
21//! ```
22//!
23
24use alloc::string::String;
25use alloc::vec;
26use alloc::vec::Vec;
27use core::hash::Hash;
28
29use crate::IndexableValues;
30
31/// Type alias for update operation values.
32type UpdateValues = Vec<(MaybeValue<String, Vec<u8>>, MaybeValue<String, Vec<u8>>)>;
33
34/// Type alias for parsed values result.
35type ParsedValues = (Vec<MaybeValue<String, Vec<u8>>>, usize);
36use crate::builders::{ChangesetFormat, DiffSet, DiffSetBuilder, Operation, PatchsetFormat};
37use crate::encoding::{MaybeValue, Value, decode_value, markers, op_codes};
38use crate::schema::{DynTable, SchemaWithPK};
39
40/// Errors that can occur during parsing.
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42pub enum ParseError {
43    /// Unexpected end of input.
44    #[error("Unexpected end of input at position {0}")]
45    UnexpectedEof(usize),
46
47    /// Invalid table marker (expected 'T' or 'P').
48    #[error("Invalid table marker 0x{0:02x} at position {1}")]
49    InvalidTableMarker(u8, usize),
50
51    /// Invalid operation code.
52    #[error("Invalid operation code 0x{0:02x} at position {1}")]
53    InvalidOpCode(u8, usize),
54
55    /// Invalid UTF-8 in table name.
56    #[error("Invalid UTF-8 in table name at position {0}")]
57    InvalidTableName(usize),
58
59    /// Failed to decode a value.
60    #[error("Failed to decode value at position {0}")]
61    InvalidValue(usize),
62
63    /// Table name not null-terminated.
64    #[error("Table name not null-terminated")]
65    UnterminatedTableName,
66
67    /// Mixed format markers in the same file.
68    #[error("Mixed format markers: expected {expected:?}, found {found:?} at position {position}")]
69    MixedFormats {
70        /// The expected format marker.
71        expected: FormatMarker,
72        /// The found format marker.
73        found: FormatMarker,
74        /// The position where the mismatch occurred.
75        position: usize,
76    },
77}
78
79/// The detected format marker.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum FormatMarker {
82    /// Changeset format ('T' marker).
83    Changeset,
84    /// Patchset format ('P' marker).
85    Patchset,
86}
87
88/// A table schema parsed from binary changeset/patchset data.
89///
90/// This type implements [`DynTable`] and [`SchemaWithPK`], allowing it
91/// to be used with [`DiffSetBuilder`].
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93pub struct TableSchema<S> {
94    /// The table name.
95    name: S,
96    /// Number of columns.
97    column_count: usize,
98    /// Primary key flags - raw bytes from the changeset/patchset.
99    ///
100    /// Each byte represents the 1-based ordinal position in the composite PK,
101    /// or 0 if the column is not part of the primary key.
102    /// For example, `[1, 0, 2]` means column 0 is the first PK column,
103    /// column 1 is not a PK column, and column 2 is the second PK column.
104    pk_flags: Vec<u8>,
105}
106
107impl<S> TableSchema<S> {
108    /// Create a new parsed table schema.
109    #[inline]
110    #[must_use]
111    pub fn new(name: S, column_count: usize, pk_flags: Vec<u8>) -> Self {
112        debug_assert_eq!(pk_flags.len(), column_count);
113        Self {
114            name,
115            column_count,
116            pk_flags,
117        }
118    }
119
120    /// Returns the name of the table.
121    #[inline]
122    #[must_use]
123    pub fn name(&self) -> &S {
124        &self.name
125    }
126
127    /// Returns the raw primary-key flags. Each byte at index `i`
128    /// represents column `i`: `0` means the column is not part of the
129    /// primary key, and a non-zero value `k` means it is the `k`-th
130    /// column in the composite primary key.
131    #[inline]
132    #[must_use]
133    pub fn pk_flags(&self) -> &[u8] {
134        &self.pk_flags
135    }
136
137    /// Get the indices of primary key columns, in PK order.
138    #[must_use]
139    pub(crate) fn pk_indices(&self) -> Vec<usize> {
140        // Collect (col_idx, pk_ordinal) pairs for non-zero entries
141        let mut pk_cols: Vec<(usize, u8)> = self
142            .pk_flags
143            .iter()
144            .enumerate()
145            .filter_map(|(i, &pk_ordinal)| {
146                if pk_ordinal > 0 {
147                    Some((i, pk_ordinal))
148                } else {
149                    None
150                }
151            })
152            .collect();
153        // Sort by pk_ordinal to get correct PK order
154        pk_cols.sort_by_key(|(_, ordinal)| *ordinal);
155        pk_cols.into_iter().map(|(idx, _)| idx).collect()
156    }
157}
158
159impl<S: AsRef<str> + Clone + Eq + core::fmt::Debug> DynTable for TableSchema<S> {
160    #[inline]
161    fn name(&self) -> &str {
162        self.name.as_ref()
163    }
164
165    #[inline]
166    fn number_of_columns(&self) -> usize {
167        self.column_count
168    }
169
170    #[inline]
171    fn write_pk_flags(&self, buf: &mut [u8]) {
172        assert_eq!(buf.len(), self.column_count);
173        buf.copy_from_slice(&self.pk_flags);
174    }
175}
176
177impl<N: AsRef<str> + Clone + core::hash::Hash + Eq + core::fmt::Debug> SchemaWithPK
178    for TableSchema<N>
179{
180    fn number_of_primary_keys(&self) -> usize {
181        self.pk_flags.iter().filter(|&&b| b > 0).count()
182    }
183
184    fn primary_key_index(&self, col_idx: usize) -> Option<usize> {
185        self.pk_flags.get(col_idx).and_then(|&pk_ordinal| {
186            if pk_ordinal > 0 {
187                Some(usize::from(pk_ordinal - 1))
188            } else {
189                None
190            }
191        })
192    }
193
194    fn extract_pk<S, B>(
195        &self,
196        values: &impl IndexableValues<Text = S, Binary = B>,
197    ) -> alloc::vec::Vec<Value<S, B>>
198    where
199        S: Clone,
200        B: Clone,
201    {
202        self.pk_indices()
203            .into_iter()
204            .map(|i| {
205                values
206                    .get(i)
207                    .expect("primary key column index out of bounds, values shorter than schema")
208            })
209            .collect()
210    }
211}
212
213/// A parsed changeset or patchset.
214///
215/// This represents a frozen (immutable) diffset produced by the binary parser.
216/// To modify it, convert it to a [`DiffSetBuilder`] using `Into::into`.
217#[derive(Debug, Clone, Eq)]
218pub enum ParsedDiffSet {
219    /// A parsed changeset.
220    Changeset(DiffSet<ChangesetFormat, TableSchema<String>, String, Vec<u8>>),
221    /// A parsed patchset.
222    Patchset(DiffSet<PatchsetFormat, TableSchema<String>, String, Vec<u8>>),
223}
224
225impl PartialEq for ParsedDiffSet {
226    fn eq(&self, other: &Self) -> bool {
227        let self_empty = match self {
228            ParsedDiffSet::Changeset(d) => d.is_empty(),
229            ParsedDiffSet::Patchset(d) => d.is_empty(),
230        };
231        let other_empty = match other {
232            ParsedDiffSet::Changeset(d) => d.is_empty(),
233            ParsedDiffSet::Patchset(d) => d.is_empty(),
234        };
235
236        if self_empty && other_empty {
237            return true;
238        }
239
240        // Otherwise compare by variant and content
241        match (self, other) {
242            (ParsedDiffSet::Changeset(a), ParsedDiffSet::Changeset(b)) => a == b,
243            (ParsedDiffSet::Patchset(a), ParsedDiffSet::Patchset(b)) => a == b,
244            _ => false,
245        }
246    }
247}
248
249impl TryFrom<&[u8]> for ParsedDiffSet {
250    type Error = ParseError;
251
252    fn try_from(data: &[u8]) -> Result<Self, Self::Error> {
253        Self::parse(data)
254    }
255}
256
257impl From<ParsedDiffSet> for Vec<u8> {
258    fn from(diffset: ParsedDiffSet) -> Self {
259        match diffset {
260            ParsedDiffSet::Changeset(d) => d.into(),
261            ParsedDiffSet::Patchset(d) => d.into(),
262        }
263    }
264}
265
266impl ParsedDiffSet {
267    /// Parse binary data into a frozen [`DiffSet`].
268    ///
269    /// The format (changeset vs patchset) is determined by the first table marker.
270    ///
271    /// # Errors
272    ///
273    /// Returns a `ParseError` if the data is malformed or contains invalid values.
274    pub fn parse(data: &[u8]) -> Result<Self, ParseError> {
275        if data.is_empty() {
276            // Empty data defaults to changeset
277            return Ok(ParsedDiffSet::Changeset(DiffSet::default()));
278        }
279
280        // Peek at the first byte to determine format
281        match data[0] {
282            markers::CHANGESET => {
283                let diffset = parse_as_changeset(data)?;
284                Ok(ParsedDiffSet::Changeset(diffset))
285            }
286            markers::PATCHSET => {
287                let diffset = parse_as_patchset(data)?;
288                Ok(ParsedDiffSet::Patchset(diffset))
289            }
290            b => Err(ParseError::InvalidTableMarker(b, 0)),
291        }
292    }
293
294    /// Returns true if this is a changeset.
295    #[must_use]
296    pub fn is_changeset(&self) -> bool {
297        matches!(self, ParsedDiffSet::Changeset(_))
298    }
299
300    /// Returns true if this is a patchset.
301    #[must_use]
302    pub fn is_patchset(&self) -> bool {
303        matches!(self, ParsedDiffSet::Patchset(_))
304    }
305
306    /// Returns the table schemas for all tables with non-empty operations.
307    #[must_use]
308    pub fn table_schemas(&self) -> Vec<&TableSchema<String>> {
309        match self {
310            ParsedDiffSet::Changeset(d) => d
311                .tables
312                .iter()
313                .filter(|(_, ops)| !ops.is_empty())
314                .map(|(schema, _)| schema)
315                .collect(),
316            ParsedDiffSet::Patchset(d) => d
317                .tables
318                .iter()
319                .filter(|(_, ops)| !ops.is_empty())
320                .map(|(schema, _)| schema)
321                .collect(),
322        }
323    }
324}
325
326/// Parse binary data as a changeset.
327///
328/// # Errors
329///
330/// Returns a `ParseError` if the data is malformed or not a valid changeset.
331fn parse_as_changeset(
332    data: &[u8],
333) -> Result<DiffSet<ChangesetFormat, TableSchema<String>, String, Vec<u8>>, ParseError> {
334    let mut builder: DiffSetBuilder<ChangesetFormat, TableSchema<String>, String, Vec<u8>> =
335        DiffSetBuilder::new();
336    let mut pos = 0;
337
338    while pos < data.len() {
339        let (schema, format, header_len) = parse_table_header(&data[pos..], pos)?;
340        if format != FormatMarker::Changeset {
341            return Err(ParseError::MixedFormats {
342                expected: FormatMarker::Changeset,
343                found: format,
344                position: pos,
345            });
346        }
347        pos += header_len;
348
349        while pos < data.len() {
350            let byte = data[pos];
351            if byte == markers::CHANGESET || byte == markers::PATCHSET {
352                break;
353            }
354            let op_len = parse_changeset_operation(&data[pos..], pos, &schema, &mut builder)?;
355            pos += op_len;
356        }
357    }
358
359    Ok(builder.into())
360}
361
362/// Parse binary data as a patchset.
363///
364/// # Errors
365///
366/// Returns a `ParseError` if the data is malformed or not a valid patchset.
367fn parse_as_patchset(
368    data: &[u8],
369) -> Result<DiffSet<PatchsetFormat, TableSchema<String>, String, Vec<u8>>, ParseError> {
370    let mut builder: DiffSetBuilder<PatchsetFormat, TableSchema<String>, String, Vec<u8>> =
371        DiffSetBuilder::new();
372    let mut pos = 0;
373
374    while pos < data.len() {
375        let (schema, format, header_len) = parse_table_header(&data[pos..], pos)?;
376        if format != FormatMarker::Patchset {
377            return Err(ParseError::MixedFormats {
378                expected: FormatMarker::Patchset,
379                found: format,
380                position: pos,
381            });
382        }
383        pos += header_len;
384
385        while pos < data.len() {
386            let byte = data[pos];
387            if byte == markers::CHANGESET || byte == markers::PATCHSET {
388                break;
389            }
390            let op_len = parse_patchset_operation(&data[pos..], pos, &schema, &mut builder)?;
391            pos += op_len;
392        }
393    }
394
395    Ok(builder.into())
396}
397
398/// Parse a table header and return the schema.
399fn parse_table_header(
400    data: &[u8],
401    base_pos: usize,
402) -> Result<(TableSchema<String>, FormatMarker, usize), ParseError> {
403    let mut pos = 0;
404
405    if data.is_empty() {
406        return Err(ParseError::UnexpectedEof(base_pos));
407    }
408    let format = match data[pos] {
409        markers::CHANGESET => FormatMarker::Changeset,
410        markers::PATCHSET => FormatMarker::Patchset,
411        b => return Err(ParseError::InvalidTableMarker(b, base_pos + pos)),
412    };
413    pos += 1;
414
415    if pos >= data.len() {
416        return Err(ParseError::UnexpectedEof(base_pos + pos));
417    }
418    let column_count = data[pos] as usize;
419    pos += 1;
420
421    if pos + column_count > data.len() {
422        return Err(ParseError::UnexpectedEof(base_pos + pos));
423    }
424    let pk_flags: Vec<u8> = data[pos..pos + column_count].to_vec();
425    pos += column_count;
426
427    let name_start = pos;
428    while pos < data.len() && data[pos] != 0 {
429        pos += 1;
430    }
431    if pos >= data.len() {
432        return Err(ParseError::UnterminatedTableName);
433    }
434    let name = String::from_utf8(data[name_start..pos].to_vec())
435        .map_err(|_| ParseError::InvalidTableName(base_pos + name_start))?;
436    pos += 1;
437
438    Ok((TableSchema::new(name, column_count, pk_flags), format, pos))
439}
440
441/// Parse operation header (`op_code` + indirect flag).
442///
443/// Returns `(op_code, indirect, bytes_consumed)`. Any non-zero indirect byte
444/// parses as `true` to match SQLite's permissive treatment of the flag.
445fn parse_operation_header(data: &[u8], base_pos: usize) -> Result<(u8, bool, usize), ParseError> {
446    if data.len() < 2 {
447        return Err(ParseError::UnexpectedEof(base_pos));
448    }
449    Ok((data[0], data[1] != 0, 2))
450}
451
452/// Parse a changeset operation.
453fn parse_changeset_operation(
454    data: &[u8],
455    base_pos: usize,
456    schema: &TableSchema<String>,
457    builder: &mut DiffSetBuilder<ChangesetFormat, TableSchema<String>, String, Vec<u8>>,
458) -> Result<usize, ParseError> {
459    let (op_code, indirect, mut pos) = parse_operation_header(data, base_pos)?;
460
461    match op_code {
462        op_codes::INSERT => {
463            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
464            pos += len;
465            let values: Vec<Value<String, Vec<u8>>> = values
466                .into_iter()
467                .map(|v| v.unwrap_or(Value::Null))
468                .collect();
469            let pk = schema.extract_pk(&values);
470            builder.add_operation(schema, pk, Operation::Insert { values, indirect });
471        }
472        op_codes::DELETE => {
473            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
474            pos += len;
475            let values: Vec<Value<String, Vec<u8>>> = values
476                .into_iter()
477                .map(|v| v.unwrap_or(Value::Null))
478                .collect();
479            let pk = schema.extract_pk(&values);
480            builder.add_operation(
481                schema,
482                pk,
483                Operation::Delete {
484                    data: values,
485                    indirect,
486                },
487            );
488        }
489        op_codes::UPDATE => {
490            let (old_values, old_len) =
491                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
492            pos += old_len;
493            let (new_values, new_len) =
494                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
495            pos += new_len;
496            // Extract PK using old values (convert None to Null)
497            let pk_values: Vec<Value<String, Vec<u8>>> = old_values
498                .iter()
499                .map(|v| v.clone().unwrap_or(Value::Null))
500                .collect();
501            let pk = schema.extract_pk(&pk_values);
502            let values: UpdateValues = old_values.into_iter().zip(new_values).collect();
503            builder.add_operation(schema, pk, Operation::Update { values, indirect });
504        }
505        _ => return Err(ParseError::InvalidOpCode(op_code, base_pos)),
506    }
507
508    Ok(pos)
509}
510
511/// Parse a patchset operation.
512fn parse_patchset_operation(
513    data: &[u8],
514    base_pos: usize,
515    schema: &TableSchema<String>,
516    builder: &mut DiffSetBuilder<PatchsetFormat, TableSchema<String>, String, Vec<u8>>,
517) -> Result<usize, ParseError> {
518    let (op_code, indirect, mut pos) = parse_operation_header(data, base_pos)?;
519
520    match op_code {
521        op_codes::INSERT => {
522            let (values, len) = parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
523            pos += len;
524            let values: Vec<Value<String, Vec<u8>>> = values
525                .into_iter()
526                .map(|v| v.unwrap_or(Value::Null))
527                .collect();
528            let pk = schema.extract_pk(&values);
529            builder.add_operation(schema, pk, Operation::Insert { values, indirect });
530        }
531        op_codes::DELETE => {
532            // Patchset DELETE: only PK values in column order
533            let pk_count = schema.pk_flags.iter().filter(|&&b| b > 0).count();
534            let (pk_values, len) = parse_values(&data[pos..], base_pos + pos, pk_count)?;
535            pos += len;
536            // Expand PK values to full row, then extract_pk to get ordinal-sorted PK.
537            // This is needed because the binary format stores PKs in column order,
538            // but the builder stores them sorted by pk_ordinal (matching the serializer).
539            let full_values = expand_pk_values(&schema.pk_flags, pk_values, schema.column_count);
540            // Convert MaybeValue to Value for extract_pk (PK values should always be defined)
541            let full_values_concrete: Vec<Value<String, Vec<u8>>> = full_values
542                .into_iter()
543                .map(|v| v.unwrap_or(Value::Null))
544                .collect();
545            let pk = schema.extract_pk(&full_values_concrete);
546            builder.add_operation(schema, pk, Operation::Delete { data: (), indirect });
547        }
548        op_codes::UPDATE => {
549            // Patchset UPDATE old values contain PK column values (non-PK are Undefined).
550            // New values contain all column updates (Undefined for unchanged columns).
551            let (old_values, old_len) =
552                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
553            pos += old_len;
554            let (new_values, new_len) =
555                parse_values(&data[pos..], base_pos + pos, schema.column_count)?;
556            pos += new_len;
557            // Extract PK from old values (that's where build() writes PK columns)
558            let pk_values: Vec<Value<String, Vec<u8>>> = old_values
559                .iter()
560                .map(|v| v.clone().unwrap_or(Value::Null))
561                .collect();
562            let pk = schema.extract_pk(&pk_values);
563            let values: Vec<((), MaybeValue<String, Vec<u8>>)> =
564                new_values.into_iter().map(|v| ((), v)).collect();
565            builder.add_operation(schema, pk, Operation::Update { values, indirect });
566        }
567        _ => return Err(ParseError::InvalidOpCode(op_code, base_pos)),
568    }
569
570    Ok(pos)
571}
572
573/// Expand PK-only values to full row with None (undefined) for non-PK columns.
574///
575/// The `pk_flags` are raw bytes where non-zero means the column is part of the PK.
576/// PK values are expected in the order they appear in `pk_flags` (not sorted by ordinal).
577fn expand_pk_values(
578    pk_flags: &[u8],
579    pk_values: Vec<MaybeValue<String, Vec<u8>>>,
580    column_count: usize,
581) -> Vec<MaybeValue<String, Vec<u8>>> {
582    let mut full: Vec<MaybeValue<String, Vec<u8>>> = vec![None; column_count];
583    let mut pk_iter = pk_values.into_iter();
584    for (i, &pk_ordinal) in pk_flags.iter().enumerate() {
585        if pk_ordinal > 0
586            && let Some(v) = pk_iter.next()
587        {
588            full[i] = v;
589        }
590    }
591    full
592}
593
594/// Parse a sequence of values.
595fn parse_values(data: &[u8], base_pos: usize, count: usize) -> Result<ParsedValues, ParseError> {
596    let mut values = Vec::with_capacity(count);
597    let mut pos = 0;
598
599    for _ in 0..count {
600        let (value, value_len) =
601            decode_value(&data[pos..]).ok_or(ParseError::InvalidValue(base_pos + pos))?;
602        values.push(value);
603        pos += value_len;
604    }
605
606    Ok((values, pos))
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use alloc::vec;
613
614    #[test]
615    fn test_parse_empty() {
616        let result = ParsedDiffSet::parse(&[]);
617        assert!(result.is_ok());
618        assert!(result.unwrap().is_changeset());
619    }
620
621    #[test]
622    fn test_parse_table_header() {
623        // 'T', 2 columns, pk_flags [1, 0], table name "t\0"
624        let data = [b'T', 2, 1, 0, b't', 0];
625        let (schema, format, len) = parse_table_header(&data, 0).unwrap();
626
627        assert_eq!(format, FormatMarker::Changeset);
628        assert_eq!(schema.column_count, 2);
629        assert_eq!(schema.pk_flags, vec![1, 0]); // Raw bytes: 1 = first PK column, 0 = not PK
630        assert_eq!(schema.name, "t");
631        assert_eq!(len, 6);
632    }
633
634    #[test]
635    fn test_parse_insert_changeset() {
636        // Table header + INSERT with integer 1 and text "a"
637        let mut data = vec![b'T', 2, 1, 0, b't', 0];
638        // INSERT opcode, indirect=0
639        data.push(op_codes::INSERT);
640        data.push(0);
641        // Integer 1 (type 1, 8 bytes)
642        data.push(0x01);
643        data.extend(&1i64.to_be_bytes());
644        // Text "a" (type 3, length 1, "a")
645        data.push(0x03);
646        data.push(1);
647        data.push(b'a');
648
649        let parsed = ParsedDiffSet::parse(&data).unwrap();
650        assert!(parsed.is_changeset());
651    }
652
653    #[test]
654    fn test_parse_delete_changeset() {
655        let mut data = vec![b'T', 2, 1, 0, b't', 0];
656        data.push(op_codes::DELETE);
657        data.push(0);
658        // Integer 1
659        data.push(0x01);
660        data.extend(&1i64.to_be_bytes());
661        // Text "a"
662        data.push(0x03);
663        data.push(1);
664        data.push(b'a');
665
666        let parsed = ParsedDiffSet::parse(&data).unwrap();
667        assert!(parsed.is_changeset());
668    }
669
670    #[test]
671    fn test_parse_delete_patchset() {
672        // Patchset DELETE only has PK values
673        let mut data = vec![b'P', 2, 1, 0, b't', 0];
674        data.push(op_codes::DELETE);
675        data.push(0);
676        // Only PK value (integer 1)
677        data.push(0x01);
678        data.extend(&1i64.to_be_bytes());
679
680        let parsed = ParsedDiffSet::parse(&data).unwrap();
681        assert!(parsed.is_patchset());
682    }
683
684    #[test]
685    fn test_parse_update_changeset() {
686        let mut data = vec![b'T', 2, 1, 0, b't', 0];
687        data.push(op_codes::UPDATE);
688        data.push(0);
689        // Old values: integer 1, text "a"
690        data.push(0x01);
691        data.extend(&1i64.to_be_bytes());
692        data.push(0x03);
693        data.push(1);
694        data.push(b'a');
695        // New values: integer 1, text "b"
696        data.push(0x01);
697        data.extend(&1i64.to_be_bytes());
698        data.push(0x03);
699        data.push(1);
700        data.push(b'b');
701
702        let parsed = ParsedDiffSet::parse(&data).unwrap();
703        assert!(parsed.is_changeset());
704    }
705
706    #[test]
707    fn test_is_changeset() {
708        let data = vec![b'T', 1, 1, b't', 0];
709        let parsed = ParsedDiffSet::parse(&data).unwrap();
710        assert!(parsed.is_changeset());
711        assert!(!parsed.is_patchset());
712    }
713
714    #[test]
715    fn test_is_patchset() {
716        let data = vec![b'P', 1, 1, b't', 0];
717        let parsed = ParsedDiffSet::parse(&data).unwrap();
718        assert!(parsed.is_patchset());
719        assert!(!parsed.is_changeset());
720    }
721
722    #[test]
723    fn test_parsed_table_schema_dyn_table() {
724        let schema: TableSchema<String> = TableSchema::new("users".into(), 3, vec![1, 0, 0]);
725        assert_eq!(schema.name(), "users");
726        assert_eq!(schema.number_of_columns(), 3);
727
728        let mut buf = [0u8; 3];
729        schema.write_pk_flags(&mut buf);
730        assert_eq!(buf, [1, 0, 0]);
731    }
732
733    #[test]
734    fn test_parsed_table_schema_extract_pk() {
735        let schema: TableSchema<String> = TableSchema::new("users".into(), 3, vec![1, 0, 2]);
736        let values: Vec<Value<String, Vec<u8>>> = vec![
737            Value::Integer(1),
738            Value::Text("alice".into()),
739            Value::Integer(100),
740        ];
741        let pk = schema.extract_pk(&values);
742        let expected: Vec<Value<String, Vec<u8>>> = vec![Value::Integer(1), Value::Integer(100)];
743        assert_eq!(pk, expected);
744    }
745
746    // ---- Error path tests ----
747
748    #[test]
749    fn test_parse_invalid_table_marker() {
750        let data = [0xFFu8, 1, 1, b't', 0];
751        let err = ParsedDiffSet::parse(&data).unwrap_err();
752        assert!(
753            matches!(err, ParseError::InvalidTableMarker(0xFF, 0)),
754            "got {err:?}"
755        );
756    }
757
758    #[test]
759    fn test_parse_unexpected_eof_in_table_header() {
760        // 'T' marker but no column count
761        let data = *b"T";
762        let err = ParsedDiffSet::parse(&data).unwrap_err();
763        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
764    }
765
766    #[test]
767    fn test_parse_unexpected_eof_in_pk_flags() {
768        // 'T', column count 3, but only 1 PK flag byte
769        let data = [b'T', 3, 1];
770        let err = ParsedDiffSet::parse(&data).unwrap_err();
771        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
772    }
773
774    #[test]
775    fn test_parse_unterminated_table_name() {
776        // 'T', 1 column, pk_flags [1], then "abc" with no null terminator
777        let data = [b'T', 1, 1, b'a', b'b', b'c'];
778        let err = ParsedDiffSet::parse(&data).unwrap_err();
779        assert!(
780            matches!(err, ParseError::UnterminatedTableName),
781            "got {err:?}"
782        );
783    }
784
785    #[test]
786    fn test_parse_invalid_utf8_in_table_name() {
787        // 'T', 1 column, pk_flags [1], then 0xFF (invalid UTF-8), then null
788        let data = [b'T', 1, 1, 0xFF, 0];
789        let err = ParsedDiffSet::parse(&data).unwrap_err();
790        assert!(
791            matches!(err, ParseError::InvalidTableName(_)),
792            "got {err:?}"
793        );
794    }
795
796    #[test]
797    fn test_parse_mixed_formats_changeset_then_patchset() {
798        // First table 'T' (changeset), then second table 'P' (patchset)
799        let mut data = vec![b'T', 1, 1, b'a', 0];
800        // Now a 'P' table header without preceding operations
801        data.extend_from_slice(&[b'P', 1, 1, b'b', 0]);
802        let err = ParsedDiffSet::parse(&data).unwrap_err();
803        assert!(
804            matches!(
805                err,
806                ParseError::MixedFormats {
807                    expected: FormatMarker::Changeset,
808                    found: FormatMarker::Patchset,
809                    ..
810                }
811            ),
812            "got {err:?}"
813        );
814    }
815
816    #[test]
817    fn test_parse_mixed_formats_patchset_then_changeset() {
818        let mut data = vec![b'P', 1, 1, b'a', 0];
819        data.extend_from_slice(&[b'T', 1, 1, b'b', 0]);
820        let err = ParsedDiffSet::parse(&data).unwrap_err();
821        assert!(
822            matches!(
823                err,
824                ParseError::MixedFormats {
825                    expected: FormatMarker::Patchset,
826                    found: FormatMarker::Changeset,
827                    ..
828                }
829            ),
830            "got {err:?}"
831        );
832    }
833
834    /// Build the operation header bytes followed by a single integer payload.
835    fn make_insert_with_indirect(indirect_byte: u8) -> Vec<u8> {
836        let mut data = vec![b'T', 1, 1, b't', 0];
837        data.push(op_codes::INSERT);
838        data.push(indirect_byte);
839        // Integer 1
840        data.push(0x01);
841        data.extend(&1i64.to_be_bytes());
842        data
843    }
844
845    fn first_op_indirect_changeset(data: &[u8]) -> bool {
846        let parsed = ParsedDiffSet::parse(data).unwrap();
847        let ParsedDiffSet::Changeset(set) = parsed else {
848            panic!("expected Changeset");
849        };
850        set.tables
851            .iter()
852            .find_map(|(_schema, rows)| rows.first().map(|(_, op)| op.indirect()))
853            .expect("expected at least one op")
854    }
855
856    #[test]
857    fn test_parse_changeset_indirect_flag_set() {
858        let data = make_insert_with_indirect(1);
859        assert!(first_op_indirect_changeset(&data));
860    }
861
862    #[test]
863    fn test_parse_changeset_indirect_flag_clear() {
864        let data = make_insert_with_indirect(0);
865        assert!(!first_op_indirect_changeset(&data));
866    }
867
868    #[test]
869    fn test_parse_indirect_nonzero_treated_as_true() {
870        // Any non-zero byte must parse as indirect = true.
871        let data = make_insert_with_indirect(0x42);
872        assert!(first_op_indirect_changeset(&data));
873    }
874
875    #[test]
876    fn test_parsed_diffset_variant_mismatch_partial_eq() {
877        let changeset = ParsedDiffSet::parse(&[b'T', 1, 1, b't', 0]).unwrap();
878        let patchset = ParsedDiffSet::parse(&[b'P', 1, 1, b't', 0]).unwrap();
879        // Both are empty so PartialEq short-circuits to true. Add a real op
880        // to each so the variant-mismatch arm in the `match` is reached.
881        let mut full_changeset = vec![b'T', 1, 1, b't', 0];
882        full_changeset.push(op_codes::INSERT);
883        full_changeset.push(0);
884        full_changeset.push(0x01);
885        full_changeset.extend(&1i64.to_be_bytes());
886        let cs = ParsedDiffSet::parse(&full_changeset).unwrap();
887
888        let mut full_patchset = vec![b'P', 1, 1, b't', 0];
889        full_patchset.push(op_codes::INSERT);
890        full_patchset.push(0);
891        full_patchset.push(0x01);
892        full_patchset.extend(&1i64.to_be_bytes());
893        let ps = ParsedDiffSet::parse(&full_patchset).unwrap();
894
895        assert_ne!(cs, ps);
896        // Empty/empty still equal regardless of variant.
897        assert_eq!(changeset, patchset);
898    }
899
900    #[test]
901    fn test_parse_unexpected_eof_in_operation_header() {
902        // Valid changeset header followed by a single byte (op_code only,
903        // no indirect byte) — parse_operation_header must return UnexpectedEof.
904        let data = [b'T', 1, 1, b't', 0, op_codes::INSERT];
905        let err = ParsedDiffSet::parse(&data).unwrap_err();
906        assert!(matches!(err, ParseError::UnexpectedEof(_)), "got {err:?}");
907    }
908
909    #[test]
910    fn test_parse_patchset_indirect_flag_set() {
911        // Patchset INSERT carries full row values, same header layout.
912        let mut data = vec![b'P', 1, 1, b't', 0];
913        data.push(op_codes::INSERT);
914        data.push(1);
915        data.push(0x01);
916        data.extend(&1i64.to_be_bytes());
917
918        let parsed = ParsedDiffSet::parse(&data).unwrap();
919        let ParsedDiffSet::Patchset(set) = parsed else {
920            panic!("expected Patchset");
921        };
922        let indirect = set
923            .tables
924            .iter()
925            .find_map(|(_schema, rows)| rows.first().map(|(_, op)| op.indirect()))
926            .expect("expected at least one op");
927        assert!(indirect);
928    }
929}