mdf4-rs 0.6.0

mdf4-rs is a Rust library for working with Measurement Data Format (ASAM MDF4) files.
Documentation
mod data_block_data;
mod resolved_data_block;
pub use data_block_data::DataBlockData;
pub use resolved_data_block::ResolvedDataBlock;

use super::RawChannelGroup;
use crate::{
    Error, Result,
    blocks::{
        DataBlock, DataGroupBlock, DataListBlock, HlBlock, u64_to_usize, {BlockHeader, BlockParse},
    },
};
use alloc::string::ToString;
use alloc::vec::Vec;

#[cfg(feature = "compression")]
use crate::blocks::DzBlock;

#[derive(Debug, Clone)]
pub struct RawDataGroup {
    pub block: DataGroupBlock,
    pub channel_groups: Vec<RawChannelGroup>,
    /// Whether this data group is from an unfinalized MDF file.
    pub is_unfinalized: bool,
}
impl RawDataGroup {
    /// Collect all data blocks referenced by this data group.
    ///
    /// The returned vector contains the `DT` or `DV` blocks in the order they
    /// appear on disk, transparently following any `DL` list chains.
    ///
    /// For unfinalized MDF files, this will automatically use the correct
    /// parsing method based on the `is_unfinalized` flag.
    ///
    /// # Arguments
    /// * `mmap` - Memory mapped file containing the MDF data
    ///
    /// # Returns
    /// A vector of [`DataBlock`] objects or an [`Error`] if parsing fails.
    pub fn data_blocks<'a>(&self, mmap: &'a [u8]) -> Result<Vec<DataBlock<'a>>> {
        let mut collected_blocks = Vec::new();

        // Start at the group's primary data pointer
        let mut current_block_address = self.block.data_block_addr;
        // Guard against cyclic DL chains in corrupt files.
        let mut seen_dl = alloc::collections::BTreeSet::new();
        while current_block_address != 0 {
            if !seen_dl.insert(current_block_address) {
                return Err(Error::LinkCycle {
                    address: current_block_address,
                });
            }
            let byte_offset = current_block_address as usize;

            // Read the block header
            let block_header = BlockHeader::from_bytes(&mmap[byte_offset..byte_offset + 24])?;

            match block_header.id.as_str() {
                "##DT" | "##DV" => {
                    // Check if this is an empty block in an unfinalized file
                    // (block_len == 24 means header only, but data follows anyway)
                    let data_block = if self.is_unfinalized && block_header.length == 24 {
                        // Use unfinalized parsing - read until end of file
                        DataBlock::from_bytes_unfinalized(&mmap[byte_offset..])?
                    } else {
                        // Normal parsing
                        DataBlock::from_bytes(&mmap[byte_offset..])?
                    };
                    collected_blocks.push(data_block);
                    // No list to follow, we're done
                    current_block_address = 0;
                }
                "##DL" => {
                    // Fragmented list of data blocks
                    let data_list_block = DataListBlock::from_bytes(&mmap[byte_offset..])?;

                    // Parse each fragment in this list
                    for &fragment_address in &data_list_block.data_block_addrs {
                        if fragment_address == 0 {
                            continue;
                        }
                        let (frag_addr, _) =
                            HlBlock::skip_hierarchy_blocks(mmap, fragment_address)?;
                        let fragment_offset = u64_to_usize(frag_addr, "DL fragment address")?;
                        let fragment_block = DataBlock::from_bytes(&mmap[fragment_offset..])?;

                        collected_blocks.push(fragment_block);
                    }

                    // Move to the next DLBLOCK in the chain (0 = end)
                    current_block_address = data_list_block.next_dl_addr;
                }
                "##HL" => {
                    let len = u64_to_usize(block_header.length, "##HL")?;
                    current_block_address =
                        HlBlock::next_block_addr(&mmap[byte_offset..byte_offset + len])?;
                }

                unexpected_id => {
                    return Err(Error::BlockIDError {
                        actual: unexpected_id.to_string(),
                        expected: "##DT / ##DV / ##DL / ##HL".to_string(),
                    });
                }
            }
        }

        Ok(collected_blocks)
    }

    /// Collect all data blocks, decompressing DZ blocks if needed.
    ///
    /// This method handles both regular DT/DV blocks and compressed DZ blocks.
    /// For DZ blocks, the data is decompressed and stored as owned data.
    ///
    /// # Arguments
    /// * `mmap` - Memory mapped file containing the MDF data
    ///
    /// # Returns
    /// A vector of [`ResolvedDataBlock`] objects or an [`Error`] if parsing fails.
    ///
    /// # Feature Requirements
    /// - Without `compression` feature: Returns error on DZ blocks
    /// - With `compression` feature: Decompresses DZ blocks transparently
    pub fn resolved_data_blocks<'a>(&self, mmap: &'a [u8]) -> Result<Vec<ResolvedDataBlock<'a>>> {
        let mut collected_blocks = Vec::new();

        let mut current_block_address = self.block.data_block_addr;
        // Guard against cyclic DL chains in corrupt files.
        let mut seen_dl = alloc::collections::BTreeSet::new();
        while current_block_address != 0 {
            if !seen_dl.insert(current_block_address) {
                return Err(Error::LinkCycle {
                    address: current_block_address,
                });
            }
            let byte_offset = current_block_address as usize;
            let block_header = BlockHeader::from_bytes(&mmap[byte_offset..byte_offset + 24])?;

            match block_header.id.as_str() {
                "##DT" | "##DV" => {
                    let data_block = if self.is_unfinalized && block_header.length == 24 {
                        DataBlock::from_bytes_unfinalized(&mmap[byte_offset..])?
                    } else {
                        DataBlock::from_bytes(&mmap[byte_offset..])?
                    };
                    collected_blocks.push(ResolvedDataBlock {
                        block_id: if block_header.id == "##DT" {
                            "##DT"
                        } else {
                            "##DV"
                        },
                        data: DataBlockData::Borrowed(data_block.data),
                    });
                    current_block_address = 0;
                }
                #[cfg(feature = "compression")]
                "##DZ" => {
                    let dz_block = DzBlock::from_bytes(&mmap[byte_offset..])?;
                    let decompressed = dz_block.decompress()?;
                    collected_blocks.push(ResolvedDataBlock {
                        block_id: "##DT", // DZ decompresses to DT-equivalent data
                        data: DataBlockData::Owned(decompressed),
                    });
                    current_block_address = 0;
                }
                #[cfg(not(feature = "compression"))]
                "##DZ" => {
                    return Err(Error::BlockSerializationError(
                        "DZ blocks require the 'compression' feature".to_string(),
                    ));
                }
                "##DL" => {
                    let data_list_block = DataListBlock::from_bytes(&mmap[byte_offset..])?;

                    for &fragment_address in &data_list_block.data_block_addrs {
                        if fragment_address == 0 {
                            continue;
                        }
                        let (frag_addr, frag_header) =
                            HlBlock::skip_hierarchy_blocks(mmap, fragment_address)?;
                        let fragment_offset = u64_to_usize(frag_addr, "DL fragment address")?;

                        match frag_header.id.as_str() {
                            "##DT" | "##DV" => {
                                let fragment_block =
                                    DataBlock::from_bytes(&mmap[fragment_offset..])?;
                                collected_blocks.push(ResolvedDataBlock {
                                    block_id: if frag_header.id == "##DT" {
                                        "##DT"
                                    } else {
                                        "##DV"
                                    },
                                    data: DataBlockData::Borrowed(fragment_block.data),
                                });
                            }
                            #[cfg(feature = "compression")]
                            "##DZ" => {
                                let dz_block = DzBlock::from_bytes(&mmap[fragment_offset..])?;
                                let decompressed = dz_block.decompress()?;
                                collected_blocks.push(ResolvedDataBlock {
                                    block_id: "##DT",
                                    data: DataBlockData::Owned(decompressed),
                                });
                            }
                            #[cfg(not(feature = "compression"))]
                            "##DZ" => {
                                return Err(Error::BlockSerializationError(
                                    "DZ blocks require the 'compression' feature".to_string(),
                                ));
                            }
                            other => {
                                return Err(Error::BlockIDError {
                                    actual: other.to_string(),
                                    expected: "##DT / ##DV / ##DZ".to_string(),
                                });
                            }
                        }
                    }

                    current_block_address = data_list_block.next_dl_addr;
                }
                "##HL" => {
                    let len = u64_to_usize(block_header.length, "##HL")?;
                    current_block_address =
                        HlBlock::next_block_addr(&mmap[byte_offset..byte_offset + len])?;
                }
                unexpected_id => {
                    return Err(Error::BlockIDError {
                        actual: unexpected_id.to_string(),
                        expected: "##DT / ##DV / ##DL / ##DZ / ##HL".to_string(),
                    });
                }
            }
        }

        Ok(collected_blocks)
    }
}

#[cfg(test)]
mod hl_chain_tests {
    //! `##HL` may appear in the `##DG` measurement-data chain before `##DL` / `##DT`.
    //! See `tests/data/sample_with_hl.mf4` (same bytes as `sample_with_hl_in_data_chain_loads`).

    use super::RawDataGroup;
    use crate::blocks::{BlockParse, DataGroupBlock};

    #[test]
    fn data_blocks_skip_hl_before_dt_in_chain() {
        const SAMPLE: &[u8] = include_bytes!("../../tests/data/sample_with_hl.mf4");
        const DG_OFF: usize = 1400;

        let dg = DataGroupBlock::from_bytes(&SAMPLE[DG_OFF..DG_OFF + 64]).expect("DG in fixture");
        let group = RawDataGroup {
            block: dg,
            channel_groups: Vec::new(),
            is_unfinalized: false,
        };

        let blocks = group
            .data_blocks(SAMPLE)
            .expect("reader should skip ##HL and collect DT blocks");
        assert!(
            !blocks.is_empty(),
            "fixture contains measurement data after ##HL"
        );
    }
}