Skip to main content

hdds_recording/format/
mod.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! Recording file formats.
5//!
6//! Supports:
7//! - Native `.hdds` format (default)
8//! - MCAP export (optional feature)
9
10pub mod hdds;
11
12#[cfg(feature = "mcap")]
13mod mcap_export;
14
15pub use hdds::{
16    FileHeader, FormatError, HddsFormat, HddsReader, HddsWriter, IndexEntry, SegmentHeader,
17    FORMAT_VERSION, MAGIC,
18};
19
20#[cfg(feature = "mcap")]
21pub use mcap_export::{convert_hdds_to_mcap, McapError, McapExporter};
22
23use serde::{Deserialize, Serialize};
24
25/// A recorded DDS message.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Message {
28    /// Timestamp in nanoseconds since recording start.
29    pub timestamp_nanos: u64,
30
31    /// Topic name.
32    pub topic_name: String,
33
34    /// Type name.
35    pub type_name: String,
36
37    /// Writer GUID (hex encoded).
38    pub writer_guid: String,
39
40    /// Sequence number.
41    pub sequence_number: u64,
42
43    /// Serialized payload (CDR encoded).
44    pub payload: Vec<u8>,
45
46    /// QoS profile hash (for grouping).
47    pub qos_hash: u32,
48}
49
50/// Recording metadata (stored in file header).
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RecordingMetadata {
53    /// Recording start time (ISO 8601).
54    pub start_time: String,
55
56    /// Domain ID.
57    pub domain_id: u32,
58
59    /// Recording host name.
60    pub hostname: Option<String>,
61
62    /// HDDS version used for recording.
63    pub hdds_version: String,
64
65    /// Topic list with type information.
66    pub topics: Vec<TopicInfo>,
67
68    /// Optional description.
69    pub description: Option<String>,
70}
71
72/// Topic information for metadata.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct TopicInfo {
75    /// Topic name.
76    pub name: String,
77
78    /// Type name.
79    pub type_name: String,
80
81    /// Message count in recording.
82    pub message_count: u64,
83
84    /// QoS profile (simplified).
85    pub reliability: String,
86    pub durability: String,
87}
88
89impl Default for RecordingMetadata {
90    fn default() -> Self {
91        Self {
92            start_time: chrono::Utc::now().to_rfc3339(),
93            domain_id: 0,
94            hostname: hostname::get().ok().and_then(|h| h.into_string().ok()),
95            hdds_version: env!("CARGO_PKG_VERSION").to_string(),
96            topics: Vec::new(),
97            description: None,
98        }
99    }
100}
101
102/// Supported output formats.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum OutputFormat {
105    /// Native HDDS format (.hdds)
106    Hdds,
107    /// MCAP format (.mcap) - requires feature
108    Mcap,
109}
110
111impl OutputFormat {
112    /// Detect format from file extension.
113    pub fn from_extension(path: &std::path::Path) -> Option<Self> {
114        match path.extension().and_then(|e| e.to_str()) {
115            Some("hdds") => Some(Self::Hdds),
116            Some("mcap") => Some(Self::Mcap),
117            _ => None,
118        }
119    }
120
121    /// Get file extension for this format.
122    pub fn extension(&self) -> &'static str {
123        match self {
124            Self::Hdds => "hdds",
125            Self::Mcap => "mcap",
126        }
127    }
128}
129
130// Hostname helper (simple implementation)
131mod hostname {
132    pub fn get() -> std::io::Result<std::ffi::OsString> {
133        #[cfg(unix)]
134        {
135            use std::ffi::OsString;
136            use std::os::unix::ffi::OsStringExt;
137
138            let mut buf = vec![0u8; 256];
139            let ret = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut i8, buf.len()) };
140            if ret == 0 {
141                let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
142                buf.truncate(len);
143                Ok(OsString::from_vec(buf))
144            } else {
145                Err(std::io::Error::last_os_error())
146            }
147        }
148        #[cfg(not(unix))]
149        {
150            Ok(std::ffi::OsString::from("unknown"))
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_output_format_from_extension() {
161        use std::path::Path;
162
163        assert_eq!(
164            OutputFormat::from_extension(Path::new("test.hdds")),
165            Some(OutputFormat::Hdds)
166        );
167        assert_eq!(
168            OutputFormat::from_extension(Path::new("test.mcap")),
169            Some(OutputFormat::Mcap)
170        );
171        assert_eq!(OutputFormat::from_extension(Path::new("test.txt")), None);
172    }
173
174    #[test]
175    fn test_recording_metadata_default() {
176        let meta = RecordingMetadata::default();
177        assert_eq!(meta.domain_id, 0);
178        assert!(meta.topics.is_empty());
179    }
180
181    #[test]
182    fn test_message_serialization() {
183        let msg = Message {
184            timestamp_nanos: 1000,
185            topic_name: "Temperature".into(),
186            type_name: "sensor_msgs/Temperature".into(),
187            writer_guid: "0102030405060708090a0b0c00000302".into(),
188            sequence_number: 1,
189            payload: vec![1, 2, 3, 4],
190            qos_hash: 0x12345678,
191        };
192
193        let json = serde_json::to_string(&msg).expect("serialize");
194        let decoded: Message = serde_json::from_str(&json).expect("deserialize");
195
196        assert_eq!(decoded.topic_name, "Temperature");
197        assert_eq!(decoded.sequence_number, 1);
198    }
199}