Skip to main content

fit/
decoder.rs

1//! Streaming FIT decoder — yields one [`RawMessage`] per `next()` call.
2//!
3//! M4 scope: decode bytes into [`RawValue`]s with invalid-value detection,
4//! but **no** Profile-aware transforms (no scale/offset, no enum-string
5//! conversion, no DateTime, no SubField/Component expansion). Those land in
6//! M5.
7//!
8//! Multi-FIT chains are handled transparently: when the iterator reaches the
9//! end of one chain's data area + 2-byte CRC, it tries to parse another
10//! [`FileHeader`] from the remaining bytes and resets the local definition
11//! table. See protocol §"多 FIT 链".
12
13use std::borrow::Cow;
14
15use crate::definition::{LocalDefinitions, MessageDefinition};
16use crate::error::FitError;
17use crate::header::FileHeader;
18use crate::raw_value::{decode_value, RawValue};
19use crate::record_header::RecordHeader;
20use crate::stream::ByteStream;
21
22/// One decoded FIT message.
23///
24/// The lifetime `'a` ties developer-field byte slices to the input buffer so
25/// they can be borrowed zero-copy. Use [`RawMessage::into_owned`] to detach.
26#[derive(Debug, Clone, PartialEq)]
27pub struct RawMessage<'a> {
28    /// Profile-level message number — index into the codegen-produced
29    /// `MesgNum` enum.
30    pub global_mesg_num: u16,
31    /// Standard fields, in wire order.
32    pub fields: Vec<RawField>,
33    /// Developer fields, if any. Without a registered `field_description`
34    /// (mesg_num=206), the wire bytes are stored verbatim; resolving them
35    /// to typed values lands in M6.
36    pub dev_fields: Vec<RawDevField<'a>>,
37    /// `true` when this message is the first Data record after a chained-FIT
38    /// boundary. Upper layers (e.g. [`crate::TypedDecoder`]) use this to
39    /// reset per-chain state such as the [`crate::transforms::Accumulator`].
40    /// Always `false` for the very first message of the first chain.
41    pub starts_new_chain: bool,
42}
43
44impl<'a> RawMessage<'a> {
45    /// Look up a standard field by its definition number.
46    pub fn field(&self, field_def_num: u8) -> Option<&RawField> {
47        self.fields
48            .iter()
49            .find(|f| f.field_def_num == field_def_num)
50    }
51
52    /// Detach from the input buffer by copying any borrowed dev-field bytes
53    /// onto the heap. Yields a `'static` message that outlives the decoder.
54    pub fn into_owned(self) -> RawMessage<'static> {
55        RawMessage {
56            global_mesg_num: self.global_mesg_num,
57            fields: self.fields,
58            dev_fields: self
59                .dev_fields
60                .into_iter()
61                .map(RawDevField::into_owned)
62                .collect(),
63            starts_new_chain: self.starts_new_chain,
64        }
65    }
66}
67
68/// A standard field's decoded value.
69#[derive(Debug, Clone, PartialEq)]
70pub struct RawField {
71    /// Wire-level field definition number.
72    pub field_def_num: u8,
73    /// Decoded raw value.
74    pub value: RawValue,
75}
76
77/// A developer field's wire bytes, awaiting M6 schema resolution.
78///
79/// `bytes` is borrowed from the decoder's input slice when produced by the
80/// streaming decoder, eliminating per-message heap allocation.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct RawDevField<'a> {
83    /// Wire-level field definition number.
84    pub field_def_num: u8,
85    /// Index into the developer data ID table.
86    pub developer_data_index: u8,
87    /// Raw bytes exactly as they appeared on the wire.
88    pub bytes: Cow<'a, [u8]>,
89}
90
91impl<'a> RawDevField<'a> {
92    /// Detach the byte slice from the input buffer.
93    pub fn into_owned(self) -> RawDevField<'static> {
94        RawDevField {
95            field_def_num: self.field_def_num,
96            developer_data_index: self.developer_data_index,
97            bytes: Cow::Owned(self.bytes.into_owned()),
98        }
99    }
100}
101
102/// The streaming decoder. Implements [`Iterator`] yielding one
103/// `Result<RawMessage, FitError>` per Data record.
104///
105/// On the first call, the iterator parses the [`FileHeader`] and seeks past
106/// it. Definition records are absorbed silently into the 16-slot
107/// [`LocalDefinitions`] table; only Data records produce items. After a
108/// fatal error, all subsequent `next()` calls return `None`.
109pub struct Decoder<'a> {
110    stream: ByteStream<'a>,
111    local_defs: LocalDefinitions,
112    /// File offset of the trailing 2-byte CRC for the current chain. Set
113    /// after each `parse_header_at_cursor` call.
114    chain_crc_offset: Option<usize>,
115    /// Whether to run [`Self::parse_header_at_cursor`] before pulling records.
116    needs_header: bool,
117    /// Once any error fires, the iterator is "drained" — all subsequent
118    /// `next()` calls return `None`.
119    terminated: bool,
120    /// Most recent full timestamp seen (from field_def_num=253 in Data
121    /// messages). Used to reconstruct compressed timestamps.
122    last_timestamp: Option<u32>,
123    /// Set when a chained-FIT boundary was just crossed; consumed by the
124    /// next Data record so its [`RawMessage::starts_new_chain`] reports
125    /// `true`. Then cleared.
126    chain_just_reset: bool,
127}
128
129impl<'a> Decoder<'a> {
130    /// Construct a decoder over a byte slice. The slice may contain a
131    /// single FIT file or several FIT files concatenated end-to-end.
132    pub fn new(bytes: &'a [u8]) -> Self {
133        Self {
134            stream: ByteStream::new(bytes),
135            local_defs: LocalDefinitions::new(),
136            chain_crc_offset: None,
137            needs_header: true,
138            terminated: false,
139            last_timestamp: None,
140            chain_just_reset: false,
141        }
142    }
143
144    /// Drain the iterator into two vectors: messages and errors. Mirrors the
145    /// JS SDK's `read() -> { messages, errors }` shape. On a structural
146    /// error the iterator stops, so `errors` will hold at most one entry.
147    pub fn read_all(self) -> (Vec<RawMessage<'a>>, Vec<FitError>) {
148        let mut messages = Vec::new();
149        let mut errors = Vec::new();
150        for item in self {
151            match item {
152                Ok(m) => messages.push(m),
153                Err(e) => errors.push(e),
154            }
155        }
156        (messages, errors)
157    }
158
159    /// Parse a FileHeader at the cursor and seek past it. On success, sets
160    /// `chain_crc_offset` to the offset where the trailing CRC lives.
161    fn parse_header_at_cursor(&mut self) -> Result<(), FitError> {
162        let pos = self.stream.position();
163        let bytes = self.stream.as_slice();
164        let header = FileHeader::parse(&bytes[pos..])?;
165        let header_size = header.header_size as usize;
166        let data_size = header.data_size as usize;
167        let total = header_size + data_size + 2;
168        if bytes.len() - pos < total {
169            return Err(FitError::TooShort {
170                expected: total,
171                actual: bytes.len() - pos,
172            });
173        }
174        self.stream.seek(pos + header_size)?;
175        self.chain_crc_offset = Some(pos + header_size + data_size);
176        Ok(())
177    }
178
179    /// Reconstruct a full timestamp from a 5-bit compressed offset.
180    ///
181    /// Algorithm per protocol §2.3:
182    /// - If `offset >= last_5bits`: same epoch window.
183    /// - Else: rollover — epoch window advances by 0x20.
184    fn decode_compressed_timestamp(&mut self, timestamp_offset: u8) -> u32 {
185        let last_ts = self.last_timestamp.unwrap_or(0);
186        let last_5bits = last_ts & 0x1F;
187        let offset = u32::from(timestamp_offset);
188        let new_ts = if offset >= last_5bits {
189            (last_ts & !0x1F) | offset
190        } else {
191            (last_ts & !0x1F).wrapping_add(0x20) | offset
192        };
193        self.last_timestamp = Some(new_ts);
194        new_ts
195    }
196
197    /// Track the timestamp from a decoded Data message, if it has field 253.
198    fn track_timestamp(&mut self, msg: &RawMessage<'_>) {
199        if let Some(f) = msg.field(253) {
200            if let Some(ts) = f.value.as_u32() {
201                self.last_timestamp = Some(ts);
202            }
203        }
204    }
205
206    /// Decode the body of a Data message keyed by `local_mesg_num`. The
207    /// 1-byte record header has already been consumed.
208    fn decode_data(&mut self, local_mesg_num: u8) -> Result<RawMessage<'a>, FitError> {
209        // One stack-bound copy of the definition (≈256B with inline SmallVec)
210        // releases the borrow on `self.local_defs` so the read loop below can
211        // freely take `&mut self.stream`. Zero heap traffic in the common case.
212        let def = self.local_defs.require(local_mesg_num)?.clone();
213
214        let mut out_fields = Vec::with_capacity(def.fields.len());
215        for f in &def.fields {
216            let raw = self.stream.read_bytes(f.size as usize)?;
217            let value = decode_value(f.base_type, raw, def.endian, f.field_def_num)?;
218            out_fields.push(RawField {
219                field_def_num: f.field_def_num,
220                value,
221            });
222        }
223
224        let mut out_dev = Vec::with_capacity(def.dev_fields.len());
225        for d in &def.dev_fields {
226            let raw = self.stream.read_bytes(d.size as usize)?;
227            out_dev.push(RawDevField {
228                field_def_num: d.field_def_num,
229                developer_data_index: d.developer_data_index,
230                bytes: Cow::Borrowed(raw),
231            });
232        }
233
234        let msg = RawMessage {
235            global_mesg_num: def.global_mesg_num,
236            fields: out_fields,
237            dev_fields: out_dev,
238            starts_new_chain: false,
239        };
240        self.track_timestamp(&msg);
241        Ok(msg)
242    }
243
244    /// Decode a compressed-timestamp data message. The timestamp field
245    /// (fdn=253) is reconstructed from the compressed offset instead of being
246    /// read from the wire. All other fields are decoded normally.
247    fn decode_data_compressed(
248        &mut self,
249        local_mesg_num: u8,
250        timestamp_offset: u8,
251    ) -> Result<RawMessage<'a>, FitError> {
252        let def = self.local_defs.require(local_mesg_num)?.clone();
253        let timestamp = self.decode_compressed_timestamp(timestamp_offset);
254
255        let mut out_fields = Vec::with_capacity(def.fields.len());
256        for f in &def.fields {
257            if f.field_def_num == 253 {
258                out_fields.push(RawField {
259                    field_def_num: 253,
260                    value: RawValue::U32Scalar(timestamp),
261                });
262            } else {
263                let raw = self.stream.read_bytes(f.size as usize)?;
264                let value = decode_value(f.base_type, raw, def.endian, f.field_def_num)?;
265                out_fields.push(RawField {
266                    field_def_num: f.field_def_num,
267                    value,
268                });
269            }
270        }
271
272        let mut out_dev = Vec::with_capacity(def.dev_fields.len());
273        for d in &def.dev_fields {
274            let raw = self.stream.read_bytes(d.size as usize)?;
275            out_dev.push(RawDevField {
276                field_def_num: d.field_def_num,
277                developer_data_index: d.developer_data_index,
278                bytes: Cow::Borrowed(raw),
279            });
280        }
281
282        Ok(RawMessage {
283            global_mesg_num: def.global_mesg_num,
284            fields: out_fields,
285            dev_fields: out_dev,
286            starts_new_chain: false,
287        })
288    }
289}
290
291impl<'a> Iterator for Decoder<'a> {
292    type Item = Result<RawMessage<'a>, FitError>;
293
294    fn next(&mut self) -> Option<Self::Item> {
295        if self.terminated {
296            return None;
297        }
298
299        // First call (or after a chain boundary): parse the FileHeader.
300        if self.needs_header {
301            self.needs_header = false;
302            if let Err(e) = self.parse_header_at_cursor() {
303                self.terminated = true;
304                return Some(Err(e));
305            }
306        }
307
308        loop {
309            // End of current chain reached?
310            if let Some(crc_off) = self.chain_crc_offset {
311                if self.stream.position() >= crc_off {
312                    // Skip the 2-byte trailing CRC. We don't verify it here —
313                    // callers who care can use `fit::check_integrity` first.
314                    if self.stream.read_bytes(2).is_err() {
315                        self.terminated = true;
316                        return None;
317                    }
318                    // More bytes? Try to start a new chain. If not, we're done.
319                    if self.stream.is_empty() {
320                        self.terminated = true;
321                        return None;
322                    }
323                    self.local_defs.clear();
324                    self.last_timestamp = None;
325                    self.chain_crc_offset = None;
326                    self.chain_just_reset = true;
327                    if let Err(e) = self.parse_header_at_cursor() {
328                        self.terminated = true;
329                        return Some(Err(e));
330                    }
331                }
332            }
333
334            // Read the record header byte.
335            let hb = match self.stream.read_u8() {
336                Ok(b) => b,
337                Err(e) => {
338                    self.terminated = true;
339                    return Some(Err(e));
340                }
341            };
342
343            match RecordHeader::classify(hb) {
344                RecordHeader::Definition {
345                    local_mesg_num,
346                    has_dev_data,
347                } => match MessageDefinition::parse(&mut self.stream, has_dev_data) {
348                    Ok(def) => {
349                        self.local_defs.set(local_mesg_num, def);
350                        continue;
351                    }
352                    Err(e) => {
353                        self.terminated = true;
354                        return Some(Err(e));
355                    }
356                },
357                RecordHeader::Data { local_mesg_num } => {
358                    let mut result = self.decode_data(local_mesg_num);
359                    match &mut result {
360                        Ok(msg) => {
361                            msg.starts_new_chain = std::mem::take(&mut self.chain_just_reset)
362                        }
363                        Err(_) => self.terminated = true,
364                    }
365                    return Some(result);
366                }
367                RecordHeader::CompressedTimestamp {
368                    local_mesg_num,
369                    timestamp_offset,
370                } => {
371                    let mut result = self.decode_data_compressed(local_mesg_num, timestamp_offset);
372                    match &mut result {
373                        Ok(msg) => {
374                            msg.starts_new_chain = std::mem::take(&mut self.chain_just_reset)
375                        }
376                        Err(_) => self.terminated = true,
377                    }
378                    return Some(result);
379                }
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    /// Build a minimal but valid 14-byte header for a single in-memory FIT
390    /// file with `data_size` records bytes, plus a placeholder CRC pair.
391    fn write_fake_fit(records: &[u8]) -> Vec<u8> {
392        let data_size = records.len() as u32;
393        let mut bytes = vec![14u8, 0x20, 0xD0, 0x52];
394        bytes.extend_from_slice(&data_size.to_le_bytes());
395        bytes.extend_from_slice(b".FIT");
396        bytes.extend_from_slice(&[0, 0]); // header CRC = 0 → skip
397        bytes.extend_from_slice(records);
398        bytes.extend_from_slice(&[0, 0]); // file CRC placeholder
399        bytes
400    }
401
402    /// Build a Definition + a single Data record where the Definition has
403    /// one u32 LE field (field_def_num=0).
404    fn one_u32_record() -> Vec<u8> {
405        let mut records = Vec::new();
406        // Definition record: header = 0x40 (Definition, local=0, no dev)
407        records.push(0x40);
408        records.extend_from_slice(&[
409            0x00, 0x00, // reserved, arch=LE
410            0x00, 0x00, // global_mesg_num = 0
411            0x01, // field count
412            0x00, 0x04, 0x06, // fdn=0, size=4, base=UInt32
413        ]);
414        // Data record: header = 0x00 (Data, local=0)
415        records.push(0x00);
416        records.extend_from_slice(&[0xF8, 0xEF, 0x59, 0x3B]); // 995749880 LE (= 0x3B59EFF8)
417        records
418    }
419
420    #[test]
421    fn decodes_synthetic_one_record_file() {
422        let fit = write_fake_fit(&one_u32_record());
423        let (msgs, errs) = Decoder::new(&fit).read_all();
424        assert!(errs.is_empty(), "got errors: {errs:?}");
425        assert_eq!(msgs.len(), 1);
426        let m = &msgs[0];
427        assert_eq!(m.global_mesg_num, 0);
428        assert_eq!(m.fields.len(), 1);
429        assert_eq!(m.fields[0].field_def_num, 0);
430        assert_eq!(m.fields[0].value.as_u32(), Some(995749880));
431    }
432
433    #[test]
434    fn data_without_definition_yields_error() {
435        // Records: just a Data byte for local_mesg_num=0 with no preceding Definition.
436        let bad = vec![0x00, 0x01, 0x02, 0x03, 0x04]; // header + 4 bytes
437        let fit = write_fake_fit(&bad);
438        let (_msgs, errs) = Decoder::new(&fit).read_all();
439        assert_eq!(errs.len(), 1);
440        assert!(matches!(errs[0], FitError::UndefinedLocalMesgNum(0)));
441    }
442
443    #[test]
444    fn compressed_timestamp_without_definition_yields_error() {
445        // A compressed-timestamp byte with no prior definition → undefined local mesg.
446        let fit = write_fake_fit(&[0x80]);
447        let (_msgs, errs) = Decoder::new(&fit).read_all();
448        assert_eq!(errs.len(), 1);
449        assert!(matches!(errs[0], FitError::UndefinedLocalMesgNum(_)));
450    }
451
452    #[test]
453    fn malformed_field_definition_is_caught_at_definition() {
454        // Definition with a UInt32 field declared as size=3 (not multiple of 4).
455        let mut records = Vec::new();
456        records.push(0x40);
457        records.extend_from_slice(&[
458            0x00, 0x00, 0x00, 0x00, 0x01, // header bytes
459            0x00, 0x03, 0x06, // fdn=0, size=3, base=UInt32 — INVALID size
460        ]);
461        let fit = write_fake_fit(&records);
462        let (_, errs) = Decoder::new(&fit).read_all();
463        assert_eq!(errs.len(), 1);
464        assert!(matches!(
465            errs[0],
466            FitError::MalformedField {
467                field_def_num: 0,
468                size: 3,
469                ..
470            }
471        ));
472    }
473
474    #[test]
475    fn read_all_returns_pair() {
476        let fit = write_fake_fit(&one_u32_record());
477        let (msgs, errs) = Decoder::new(&fit).read_all();
478        assert_eq!(msgs.len(), 1);
479        assert_eq!(errs.len(), 0);
480    }
481
482    #[test]
483    fn endian_round_trip_via_definition() {
484        // Same record but architecture=BE: bytes should decode the same.
485        let mut records = Vec::new();
486        records.push(0x40);
487        records.extend_from_slice(&[
488            0x00, 0x01, // arch = BE
489            0x00, 0x00, // global_mesg_num (BE) = 0
490            0x01, 0x00, 0x04, 0x86, // fdn=0, size=4, base=UInt32 with BE flag
491        ]);
492        records.push(0x00); // Data header
493        records.extend_from_slice(&[0x3B, 0x59, 0xEF, 0xF8]); // 995749880 BE
494        let fit = write_fake_fit(&records);
495        let (msgs, errs) = Decoder::new(&fit).read_all();
496        assert!(errs.is_empty());
497        assert_eq!(msgs[0].fields[0].value.as_u32(), Some(995749880));
498    }
499
500    /// Concat two synthetic FIT files; the decoder must clear `local_defs`
501    /// at the chain boundary and successfully decode both.
502    #[test]
503    fn multi_fit_chain_resets_local_defs() {
504        let one = write_fake_fit(&one_u32_record());
505        let chained: Vec<u8> = one.iter().chain(one.iter()).copied().collect();
506        let (msgs, errs) = Decoder::new(&chained).read_all();
507        assert!(errs.is_empty(), "got errors: {errs:?}");
508        assert_eq!(msgs.len(), 2, "expected one message per chained file");
509        for m in &msgs {
510            assert_eq!(m.fields[0].value.as_u32(), Some(995749880));
511        }
512    }
513
514    // ────────────────────────────────────────────────────────────────────
515    // Compressed timestamp tests
516    // ────────────────────────────────────────────────────────────────────
517
518    /// Build a Definition for a "record" message (global_mesg_num=20) with
519    /// two fields: fdn=0 (uint8, heart_rate) and fdn=253 (uint32, timestamp).
520    fn record_definition() -> Vec<u8> {
521        let mut r = Vec::new();
522        // Definition: header=0x40, local=0
523        r.push(0x40);
524        r.extend_from_slice(&[
525            0x00, 0x00, // reserved, arch=LE
526            0x14, 0x00, // global_mesg_num = 20 (record) LE
527            0x02, // field count = 2
528            0x00, 0x01, 0x02, // fdn=0, size=1, base=uint8 (heart_rate)
529            0xFD, 0x04, 0x86, // fdn=253, size=4, base=uint32 (timestamp)
530        ]);
531        r
532    }
533
534    /// LE bytes for a u32 value.
535    fn u32_le(v: u32) -> [u8; 4] {
536        v.to_le_bytes()
537    }
538
539    #[test]
540    fn compressed_timestamp_basic() {
541        // Definition (local=0): record with hr + timestamp.
542        // Data (local=0): timestamp=1000, hr=120. Sets last_timestamp=1000.
543        // CompressedTimestamp (local=0, offset=5): timestamp should be
544        // (1000 & ~0x1F) | 5 = 997... wait, 1000 = 0x3E8, 0x3E8 & 0x1F = 0x08.
545        // offset=5 < last_5bits=8 → rollover: (1000 & !0x1F) + 0x20 | 5
546        //   = (1000 - 8) + 32 + 5 = 1029.
547        let mut records = Vec::new();
548        records.extend_from_slice(&record_definition());
549        // Data record: header=0x00 (local=0)
550        records.push(0x00);
551        records.push(120); // hr
552        records.extend_from_slice(&u32_le(1000)); // timestamp
553                                                  // CompressedTimestamp: header = 0x80 | (0 << 5) | 5 = 0x85
554                                                  //   local=0 (bits 6:5 = 00), offset=5 (bits 4:0 = 00101)
555        records.push(0x85);
556        records.push(130); // hr (only non-timestamp field)
557
558        let fit = write_fake_fit(&records);
559        let (msgs, errs) = Decoder::new(&fit).read_all();
560        assert!(errs.is_empty(), "got errors: {errs:?}");
561        assert_eq!(msgs.len(), 2);
562
563        // First message: normal data.
564        assert_eq!(msgs[0].fields[0].value.as_u8(), Some(120));
565        assert_eq!(msgs[0].fields[1].value.as_u32(), Some(1000));
566
567        // Second message: compressed timestamp.
568        assert_eq!(msgs[1].fields[0].value.as_u8(), Some(130));
569        // timestamp = (1000 & !0x1F) + 0x20 | 5 = 992 + 32 + 5 = 1029
570        assert_eq!(msgs[1].fields[1].value.as_u32(), Some(1029));
571    }
572
573    #[test]
574    fn compressed_timestamp_no_rollover() {
575        // last_timestamp = 1000 (0x3E8), last_5bits = 8.
576        // offset=10 >= 8 → no rollover: (1000 & !0x1F) | 10 = 992 | 10 = 1002.
577        let mut records = Vec::new();
578        records.extend_from_slice(&record_definition());
579        records.push(0x00); // Data header
580        records.push(120);
581        records.extend_from_slice(&u32_le(1000));
582        // Compressed: header = 0x80 | (0 << 5) | 10 = 0x8A
583        records.push(0x8A);
584        records.push(125);
585
586        let fit = write_fake_fit(&records);
587        let (msgs, errs) = Decoder::new(&fit).read_all();
588        assert!(errs.is_empty());
589        assert_eq!(msgs.len(), 2);
590        assert_eq!(msgs[1].fields[1].value.as_u32(), Some(1002));
591    }
592
593    #[test]
594    fn compressed_timestamp_rollover() {
595        // last_timestamp = 1000, last_5bits = 8.
596        // offset=3 < 8 → rollover: (1000 & !0x1F) + 0x20 | 3 = 992 + 32 + 3 = 1027.
597        let mut records = Vec::new();
598        records.extend_from_slice(&record_definition());
599        records.push(0x00);
600        records.push(120);
601        records.extend_from_slice(&u32_le(1000));
602        // Compressed: header = 0x80 | 3 = 0x83
603        records.push(0x83);
604        records.push(125);
605
606        let fit = write_fake_fit(&records);
607        let (msgs, errs) = Decoder::new(&fit).read_all();
608        assert!(errs.is_empty());
609        assert_eq!(msgs[1].fields[1].value.as_u32(), Some(1027));
610    }
611
612    #[test]
613    fn compressed_timestamp_chain_of_records() {
614        // Multiple compressed records in a row, all advancing time by 1s.
615        // Base timestamp: 995749880 (0x3B59EFF8), last_5bits = 0x18 = 24.
616        let base: u32 = 995749880;
617        let mut records = Vec::new();
618        records.extend_from_slice(&record_definition());
619
620        // Data record at base timestamp.
621        records.push(0x00);
622        records.push(100);
623        records.extend_from_slice(&u32_le(base));
624
625        // 5 compressed records, each with offset = last_5bits + 1 (no rollover).
626        // After base: last_5bits=24. offsets: 25, 26, 27, 28, 29
627        for (i, offset) in (25..30).enumerate() {
628            // Each successive offset is >= last_5bits, so no rollover.
629            // ts = (prev & !0x1F) | offset
630            let expected_ts = (base & !0x1F) | offset;
631            records.push(0x80 | offset as u8);
632            records.push(100 + i as u8);
633
634            // Verify inline (we'll check after decode too).
635            assert_eq!(expected_ts, base - (base & 0x1F) + offset);
636        }
637
638        let fit = write_fake_fit(&records);
639        let (msgs, errs) = Decoder::new(&fit).read_all();
640        assert!(errs.is_empty(), "got errors: {errs:?}");
641        assert_eq!(msgs.len(), 6);
642
643        assert_eq!(msgs[0].fields[1].value.as_u32(), Some(base));
644        for (i, offset) in (25..30).enumerate() {
645            let expected = (base & !0x1F) | offset;
646            assert_eq!(
647                msgs[i + 1].fields[1].value.as_u32(),
648                Some(expected),
649                "compressed record {i} with offset {offset}"
650            );
651        }
652    }
653
654    #[test]
655    fn compressed_timestamp_rollover_at_boundary() {
656        // last_5bits = 31 (max). offset=0 → rollover.
657        let ts: u32 = 1023; // 0x3FF, last_5bits = 31
658        let mut records = Vec::new();
659        records.extend_from_slice(&record_definition());
660        records.push(0x00);
661        records.push(100);
662        records.extend_from_slice(&u32_le(ts));
663        // Compressed: header = 0x80 | 0 = 0x80 (offset=0)
664        records.push(0x80);
665        records.push(110);
666
667        let fit = write_fake_fit(&records);
668        let (msgs, errs) = Decoder::new(&fit).read_all();
669        assert!(errs.is_empty());
670        // offset=0 < last_5bits=31 → rollover: (1023 & !0x1F) + 0x20 | 0 = 1024
671        assert_eq!(msgs[1].fields[1].value.as_u32(), Some(1024));
672    }
673
674    #[test]
675    fn compressed_timestamp_cold_start_no_prior_timestamp() {
676        // No Data message before compressed → last_timestamp is None (= 0).
677        // Compressed with offset=5: (0 & !0x1F) | 5 = 5.
678        let mut records = Vec::new();
679        records.extend_from_slice(&record_definition());
680        // Compressed: header = 0x85 (local=0, offset=5)
681        records.push(0x85);
682        records.push(100);
683
684        let fit = write_fake_fit(&records);
685        let (msgs, errs) = Decoder::new(&fit).read_all();
686        assert!(errs.is_empty());
687        assert_eq!(msgs.len(), 1);
688        assert_eq!(msgs[0].fields[1].value.as_u32(), Some(5));
689    }
690
691    #[test]
692    fn multi_chain_resets_compressed_state() {
693        // Chain 1: Data at ts=1000, then compressed.
694        // Chain 2: Data at ts=2000, then compressed — must NOT use chain 1's state.
695        let mut chain1 = Vec::new();
696        chain1.extend_from_slice(&record_definition());
697        chain1.push(0x00);
698        chain1.push(100);
699        chain1.extend_from_slice(&u32_le(1000));
700        // Compressed offset=10: (1000 & !0x1F) | 10 = 992 | 10 = 1002
701        chain1.push(0x8A);
702        chain1.push(110);
703
704        let mut chain2 = Vec::new();
705        chain2.extend_from_slice(&record_definition());
706        chain2.push(0x00);
707        chain2.push(100);
708        chain2.extend_from_slice(&u32_le(2000));
709        // Compressed offset=5: (2000 & !0x1F) | 5 = rollover → 2016+32+5? No:
710        //   2000 & 0x1F = 16, offset=5 < 16 → rollover: 1984 + 32 + 5 = 2021
711        chain2.push(0x85);
712        chain2.push(110);
713
714        let fit1 = write_fake_fit(&chain1);
715        let fit2 = write_fake_fit(&chain2);
716        let chained: Vec<u8> = fit1.iter().chain(fit2.iter()).copied().collect();
717
718        let (msgs, errs) = Decoder::new(&chained).read_all();
719        assert!(errs.is_empty(), "got errors: {errs:?}");
720        assert_eq!(msgs.len(), 4);
721
722        // Chain 1: compressed = 1002
723        assert_eq!(msgs[0].fields[1].value.as_u32(), Some(1000));
724        assert_eq!(msgs[1].fields[1].value.as_u32(), Some(1002));
725
726        // Chain 2: first record resets, compressed based on chain 2's ts
727        assert_eq!(msgs[2].fields[1].value.as_u32(), Some(2000));
728        // 2000 & 0x1F = 16, offset=5 < 16 → rollover
729        assert_eq!(msgs[3].fields[1].value.as_u32(), Some(2021));
730    }
731}