Skip to main content

re_importer/
lib.rs

1//! Handles importing of Rerun data from file using importer plugins.
2
3use std::collections::BTreeSet;
4use std::sync::{Arc, LazyLock};
5
6use re_chunk::{Chunk, ChunkResult};
7use re_log_types::{ArrowMsg, EntityPath, LogMsg, RecordingId, StoreId, TimePoint};
8
9// ----------------------------------------------------------------------------
10
11mod import_file;
12mod importer_archetype;
13mod importer_directory;
14mod importer_rrd;
15mod importer_urdf;
16
17#[cfg(not(target_arch = "wasm32"))]
18pub mod lerobot;
19
20// This importer currently only works when loading the entire dataset directory, and we cannot do that on web yet.
21#[cfg(not(target_arch = "wasm32"))]
22pub mod importer_lerobot;
23
24// This importer currently uses native-only features under the hood, and we cannot do that on web yet.
25pub mod importer_mcap;
26
27#[cfg(not(target_arch = "wasm32"))]
28mod importer_external;
29#[cfg(not(target_arch = "wasm32"))]
30pub mod importer_parquet;
31
32pub use self::import_file::{import_from_file_contents, prepare_store_info};
33pub use self::importer_archetype::ArchetypeImporter;
34pub use self::importer_directory::DirectoryImporter;
35pub use self::importer_mcap::McapImporter;
36pub use self::importer_rrd::RrdImporter;
37pub use self::importer_urdf::{UrdfImporter, UrdfTree, joint_transform as urdf_joint_transform};
38#[cfg(not(target_arch = "wasm32"))]
39pub use self::{
40    import_file::import_from_path,
41    importer_external::{
42        EXTERNAL_IMPORTER_INCOMPATIBLE_EXIT_CODE, EXTERNAL_IMPORTER_PREFIX, ExternalImporter,
43        iter_external_importers,
44    },
45    importer_lerobot::LeRobotDatasetImporter,
46    importer_parquet::ParquetImporter,
47};
48
49pub mod external {
50    pub use urdf_rs;
51}
52
53// ----------------------------------------------------------------------------
54
55/// The identifier used to enable or disable Foxglove lenses when loading MCAP files.
56pub const FOXGLOVE_LENSES_IDENTIFIER: &str = "foxglove";
57
58/// The identifier used to enable or disable URDF extraction from MCAP `robot_description` topics.
59pub const URDF_DECODER_IDENTIFIER: &str = "urdf";
60
61/// All decoder-like identifiers supported by [`McapImporter`].
62///
63/// This merges the built-in MCAP decoders from [`re_mcap`] and the semantic interpretation (e.g. lenses) that are in this crate.
64pub fn supported_mcap_decoder_identifiers(
65    raw_fallback_enabled: bool,
66) -> Vec<re_mcap::DecoderIdentifier> {
67    let mut identifiers = re_mcap::DecoderRegistry::all_builtin(raw_fallback_enabled)
68        .all_identifiers()
69        .into_iter()
70        .map(re_mcap::DecoderIdentifier::from)
71        .collect::<BTreeSet<_>>();
72
73    identifiers.extend([
74        re_mcap::DecoderIdentifier::from(FOXGLOVE_LENSES_IDENTIFIER),
75        re_mcap::DecoderIdentifier::from(URDF_DECODER_IDENTIFIER),
76    ]);
77
78    identifiers.into_iter().collect()
79}
80
81// ----------------------------------------------------------------------------
82
83/// Recommended settings for the [`Importer`].
84///
85/// The importer is free to ignore some or all of these.
86///
87/// External [`Importer`]s will be passed the following CLI parameters:
88/// * `--application-id <application_id>`
89/// * `--opened-application-id <opened_application_id>` (if set)
90/// * `--recording-id <store_id>`
91/// * `--opened-recording-id <opened_store_id>` (if set)
92/// * `--entity-path-prefix <entity_path_prefix>` (if set)
93/// * `--static` (if `timepoint` is set to the timeless timepoint)
94/// * `--timeless` \[deprecated\] (if `timepoint` is set to the timeless timepoint)
95/// * `--time_sequence <timeline1>=<seq1> <timeline2>=<seq2> ...` (if `timepoint` contains sequence data)
96/// * `--time_duration_nanos <timeline1>=<duration1> <timeline2>=<duration2> ...` (if `timepoint` contains duration data) in nanos
97/// * `--time_timestamp_nanos <timeline1>=<timestamp1> <timeline2>=<timestamp2> ...` (if `timepoint` contains timestamp data) in nanos since epoch
98#[derive(Debug, Clone)]
99pub struct ImporterSettings {
100    /// The recommended [`re_log_types::ApplicationId`] to log the data to, based on the surrounding context.
101    pub application_id: Option<re_log_types::ApplicationId>,
102
103    /// The recommended recording id to log the data to, based on the surrounding context.
104    ///
105    /// Log data to this recording if you want it to appear in a new recording shared by all
106    /// importers for the current loading session.
107    pub recording_id: RecordingId,
108
109    /// The [`re_log_types::StoreId`] that is currently opened in the viewer, if any.
110    pub opened_store_id: Option<StoreId>,
111
112    /// Whether `SetStoreInfo`s should be sent, regardless of the surrounding context.
113    ///
114    /// Only useful when creating a recording just-in-time directly in the viewer (which is what
115    /// happens when importing things into the welcome screen).
116    pub force_store_info: bool,
117
118    /// What should the logged entity paths be prefixed with?
119    pub entity_path_prefix: Option<EntityPath>,
120
121    /// At what time(s) should the data be logged to?
122    pub timepoint: Option<TimePoint>,
123
124    /// If `true`, keep reading `.rrd` files past EOF, tailing new data as it arrives.
125    ///
126    /// Defaults to `false`.
127    pub follow: bool,
128
129    /// If set, an offset in nanoseconds to add to all `TimestampNs` time columns.
130    pub timestamp_offset_ns: Option<i64>,
131
132    /// The timeline type to use for timestamp timelines.
133    ///
134    /// Defaults to [`re_log_types::TimeType::TimestampNs`].
135    /// When set to [`re_log_types::TimeType::DurationNs`], all timestamp timelines
136    /// will be created as duration timelines instead.
137    pub timeline_type: re_log_types::TimeType,
138}
139
140impl ImporterSettings {
141    #[inline]
142    pub fn recommended(recording_id: impl Into<RecordingId>) -> Self {
143        Self {
144            recording_id: recording_id.into(),
145            application_id: None,
146            opened_store_id: None,
147            force_store_info: false,
148            entity_path_prefix: None,
149            timepoint: None,
150            follow: false,
151            timestamp_offset_ns: None,
152            timeline_type: re_log_types::TimeType::TimestampNs,
153        }
154    }
155
156    /// Returns the recommended [`re_log_types::StoreId`] to log the data to.
157    pub fn recommended_store_id(&self) -> StoreId {
158        StoreId::recording(
159            self.application_id
160                .clone()
161                .unwrap_or_else(re_log_types::ApplicationId::random),
162            self.recording_id.clone(),
163        )
164    }
165
166    /// Returns the currently opened [`re_log_types::StoreId`] if any. Otherwise, returns the
167    /// recommended store id.
168    pub fn opened_store_id_or_recommended(&self) -> StoreId {
169        self.opened_store_id
170            .clone()
171            .unwrap_or_else(|| self.recommended_store_id())
172    }
173
174    /// Generates CLI flags from these settings, for external importers.
175    pub fn to_cli_args(&self) -> Vec<String> {
176        let Self {
177            application_id,
178            recording_id,
179            opened_store_id,
180            force_store_info: _,
181            entity_path_prefix,
182            timepoint,
183            follow: _,
184            timestamp_offset_ns: _,
185            timeline_type: _,
186        } = self;
187
188        let mut args = Vec::new();
189
190        if let Some(application_id) = application_id {
191            args.extend(["--application-id".to_owned(), format!("{application_id}")]);
192        }
193        args.extend(["--recording-id".to_owned(), format!("{recording_id}")]);
194
195        if let Some(opened_store_id) = opened_store_id {
196            args.extend([
197                "--opened-application-id".to_owned(),
198                format!("{}", opened_store_id.application_id()),
199            ]);
200
201            args.extend([
202                "--opened-recording-id".to_owned(),
203                format!("{}", opened_store_id.recording_id()),
204            ]);
205        }
206
207        if let Some(entity_path_prefix) = entity_path_prefix {
208            args.extend([
209                "--entity-path-prefix".to_owned(),
210                format!("{entity_path_prefix}"),
211            ]);
212        }
213
214        if let Some(timepoint) = timepoint {
215            if timepoint.is_static() {
216                args.push("--timeless".to_owned()); // for backwards compatibility
217                args.push("--static".to_owned());
218            }
219
220            for (timeline, cell) in timepoint.iter() {
221                match cell.typ() {
222                    re_log_types::TimeType::Sequence => {
223                        args.extend([
224                            "--time_sequence".to_owned(),
225                            format!("{timeline}={}", cell.value),
226                        ]);
227
228                        // for backwards compatibility:
229                        args.extend([
230                            "--sequence".to_owned(),
231                            format!("{timeline}={}", cell.value),
232                        ]);
233                    }
234                    re_log_types::TimeType::DurationNs => {
235                        args.extend([
236                            "--time_duration_nanos".to_owned(),
237                            format!("{timeline}={}", cell.value),
238                        ]);
239
240                        // for backwards compatibility:
241                        args.extend(["--time".to_owned(), format!("{timeline}={}", cell.value)]);
242                    }
243                    re_log_types::TimeType::TimestampNs => {
244                        args.extend([
245                            "--time_duration_nanos".to_owned(),
246                            format!("{timeline}={}", cell.value),
247                        ]);
248
249                        // for backwards compatibility:
250                        args.extend([
251                            "--sequence".to_owned(),
252                            format!("{timeline}={}", cell.value),
253                        ]);
254                    }
255                }
256            }
257        }
258
259        args
260    }
261}
262
263pub type ImporterName = String;
264
265/// An [`Importer`] imports data from a file path and/or a file's contents.
266///
267/// Files can be imported in 3 different ways:
268/// - via the Rerun CLI (`rerun myfile.jpeg`),
269/// - using drag-and-drop,
270/// - using the open dialog in the Rerun Viewer.
271///
272/// All these file importing methods support importing a single file, many files at once, or even
273/// folders.
274/// ⚠ Drag-and-drop of folders does not yet work on the web version of Rerun Viewer ⚠
275///
276/// We only support importing files from the local filesystem at the moment, and consequently only
277/// accept filepaths as input.
278/// [There are plans to make this generic over any URI](https://github.com/rerun-io/rerun/issues/4525).
279///
280/// Rerun comes with a few [`Importer`]s by default:
281/// - [`RrdImporter`] for [Rerun files].
282/// - [`ArchetypeImporter`] for:
283///     - [3D models]
284///     - [Images]
285///     - [Point clouds]
286///     - [Text files]
287/// - [`DirectoryImporter`] for recursively importing folders.
288/// - [`ExternalImporter`], which looks for user-defined importers in $PATH.
289///
290/// ## Registering custom importers
291///
292/// Checkout our [guide](https://www.rerun.io/docs/concepts/logging-and-ingestion/importers/overview).
293///
294/// ## Execution
295///
296/// **All** known [`Importer`]s get called when a user tries to open a file, unconditionally.
297/// This gives [`Importer`]s maximum flexibility to decide what files they are interested in, as
298/// opposed to e.g. only being able to look at files' extensions.
299///
300/// If an [`Importer`] has no interest in the given file, it should fail as soon as possible
301/// with a [`ImporterError::Incompatible`] error.
302///
303/// Iff all [`Importer`]s (including custom and external ones) return with a [`ImporterError::Incompatible`]
304/// error, the Viewer will show an error message to the user indicating that the file type is not
305/// supported.
306///
307/// On native, [`Importer`]s are executed in parallel.
308///
309/// [Rerun files]: crate::SUPPORTED_RERUN_EXTENSIONS
310/// [3D models]: crate::SUPPORTED_MESH_EXTENSIONS
311/// [Images]: crate::SUPPORTED_IMAGE_EXTENSIONS
312/// [Point clouds]: crate::SUPPORTED_POINT_CLOUD_EXTENSIONS
313/// [Text files]: crate::SUPPORTED_TEXT_EXTENSIONS
314//
315// TODO(#4525): `Importer`s should support arbitrary URIs
316// TODO(#4527): Web Viewer `?url` parameter should accept anything our `Importer`s support
317pub trait Importer: Send + Sync {
318    /// Name of the [`Importer`].
319    ///
320    /// Should be globally unique.
321    fn name(&self) -> ImporterName;
322
323    /// Imports data from a file on the local filesystem and sends it to `tx`.
324    ///
325    /// This is generally called when opening files with the Rerun CLI or via the open menu in the
326    /// Rerun Viewer on native platforms.
327    ///
328    /// The passed-in `store_id` is a shared recording created by the file importing machinery:
329    /// implementers can decide to use it or not (e.g. it might make sense to log all images with a
330    /// similar name in a shared recording, while an rrd file is already its own recording).
331    ///
332    /// `path` isn't necessarily a _file_ path, but can be a directory as well: implementers are
333    /// free to handle that however they decide.
334    ///
335    /// ## Error handling
336    ///
337    /// Most implementers of `import_from_path` are expected to be asynchronous in nature.
338    ///
339    /// Asynchronous implementers should make sure to fail early (and thus synchronously) when
340    /// possible (e.g. didn't even manage to open the file).
341    /// Otherwise, they should log errors that happen in an asynchronous context.
342    ///
343    /// If an [`Importer`] has no interest in the given file, it should fail as soon as possible
344    /// with a [`ImporterError::Incompatible`] error.
345    #[cfg(not(target_arch = "wasm32"))]
346    fn import_from_path(
347        &self,
348        settings: &ImporterSettings,
349        path: std::path::PathBuf,
350        tx: crossbeam::channel::Sender<ImportedData>,
351    ) -> Result<(), ImporterError>;
352
353    /// Imports data from in-memory file contents and sends it to `tx`.
354    ///
355    /// This is generally called when opening files via drag-and-drop or when using the web viewer.
356    ///
357    /// The passed-in `store_id` is a shared recording created by the file importing machinery:
358    /// implementers can decide to use it or not (e.g. it might make sense to log all images with a
359    /// similar name in a shared recording, while an rrd file is already its own recording).
360    ///
361    /// The `path` of the file is given for informational purposes (e.g. to extract the file's
362    /// extension): implementers should _not_ try to read from disk as there is likely isn't a
363    /// filesystem available to begin with.
364    /// `path` is guaranteed to be a file path.
365    ///
366    /// When running on the web (wasm), `filepath` only contains the file name.
367    ///
368    /// ## Error handling
369    ///
370    /// Most implementers of `import_from_file_contents` are expected to be asynchronous in nature.
371    ///
372    /// Asynchronous implementers should make sure to fail early (and thus synchronously) when
373    /// possible (e.g. didn't even manage to open the file).
374    /// Otherwise, they should log errors that happen in an asynchronous context.
375    ///
376    /// If an [`Importer`] has no interest in the given file, it should fail as soon as possible
377    /// with a [`ImporterError::Incompatible`] error.
378    fn import_from_file_contents(
379        &self,
380        settings: &ImporterSettings,
381        filepath: std::path::PathBuf,
382        contents: std::borrow::Cow<'_, [u8]>,
383        tx: crossbeam::channel::Sender<ImportedData>,
384    ) -> Result<(), ImporterError>;
385}
386
387/// Errors that might happen when importing data through an [`Importer`].
388#[derive(thiserror::Error, Debug)]
389pub enum ImporterError {
390    #[cfg(not(target_arch = "wasm32"))]
391    #[error(transparent)]
392    IO(#[from] std::io::Error),
393
394    #[error(transparent)]
395    Arrow(#[from] arrow::error::ArrowError),
396
397    #[error(transparent)]
398    Chunk(#[from] re_chunk::ChunkError),
399
400    #[error(transparent)]
401    Decode(#[from] re_log_encoding::DecodeError),
402
403    #[error("No importer support for {0:?}")]
404    Incompatible(std::path::PathBuf),
405
406    #[error(transparent)]
407    Mcap(#[from] ::mcap::McapError),
408
409    #[error("Video file is too large ({} bytes). \
410             Maximum supported blob size is ~2 GiB due to Arrow i32 offset limits.", .0)]
411    VideoTooLarge(usize),
412
413    #[error("{}", re_error::format(.0))]
414    Other(#[from] anyhow::Error),
415}
416
417impl ImporterError {
418    #[inline]
419    pub fn is_path_not_found(&self) -> bool {
420        match self {
421            #[cfg(not(target_arch = "wasm32"))]
422            Self::IO(err) => err.kind() == std::io::ErrorKind::NotFound,
423            _ => false,
424        }
425    }
426
427    #[inline]
428    pub fn is_incompatible(&self) -> bool {
429        matches!(self, Self::Incompatible { .. })
430    }
431}
432
433/// What [`Importer`]s produce.
434///
435/// This makes it trivial for [`Importer`]s to build the data in whatever form is
436/// most convenient for them, whether it is raw components, arrow chunks or even
437/// full-on [`LogMsg`]s.
438#[derive(Debug)]
439pub enum ImportedData {
440    Chunk(ImporterName, re_log_types::StoreId, Chunk),
441    ArrowMsg(ImporterName, re_log_types::StoreId, ArrowMsg),
442    LogMsg(ImporterName, LogMsg),
443}
444
445impl ImportedData {
446    /// Returns the name of the [`Importer`] that generated this data.
447    #[inline]
448    pub fn importer_name(&self) -> &ImporterName {
449        match self {
450            Self::Chunk(name, ..) | Self::ArrowMsg(name, ..) | Self::LogMsg(name, ..) => name,
451        }
452    }
453
454    /// Pack the data into a [`LogMsg`].
455    #[inline]
456    pub fn into_log_msg(self) -> ChunkResult<LogMsg> {
457        match self {
458            Self::Chunk(_name, store_id, chunk) => {
459                Ok(LogMsg::ArrowMsg(store_id, chunk.to_arrow_msg()?))
460            }
461
462            Self::ArrowMsg(_name, store_id, msg) => Ok(LogMsg::ArrowMsg(store_id, msg)),
463
464            Self::LogMsg(_name, msg) => Ok(msg),
465        }
466    }
467
468    /// Convert the data into a [`Chunk`], ignoring all non-chunk-related things.
469    pub fn into_chunk(self) -> Option<Chunk> {
470        match self {
471            Self::Chunk(_name, _store_id, chunk) => Some(chunk),
472            Self::ArrowMsg(_name, _store_id, arrow_msg) => Chunk::from_arrow_msg(&arrow_msg).ok(),
473            Self::LogMsg(_name, msg) => match msg {
474                LogMsg::ArrowMsg(_store_id, arrow_msg) => Chunk::from_arrow_msg(&arrow_msg).ok(),
475                LogMsg::SetStoreInfo { .. } | LogMsg::BlueprintActivationCommand { .. } => None,
476            },
477        }
478    }
479}
480
481// ----------------------------------------------------------------------------
482
483/// Keeps track of all builtin [`Importer`]s.
484///
485/// Lazy initialized the first time a file is opened.
486static BUILTIN_IMPORTERS: LazyLock<Vec<Arc<dyn Importer>>> = LazyLock::new(|| {
487    vec![
488        Arc::new(RrdImporter) as Arc<dyn Importer>,
489        Arc::new(ArchetypeImporter),
490        Arc::new(DirectoryImporter),
491        Arc::new(McapImporter::default()),
492        #[cfg(not(target_arch = "wasm32"))]
493        Arc::new(ParquetImporter::default()),
494        #[cfg(not(target_arch = "wasm32"))]
495        Arc::new(LeRobotDatasetImporter),
496        #[cfg(not(target_arch = "wasm32"))]
497        Arc::new(ExternalImporter),
498        Arc::new(UrdfImporter),
499    ]
500});
501
502/// Iterator over all registered [`Importer`]s.
503#[inline]
504pub fn iter_importers() -> impl Iterator<Item = Arc<dyn Importer>> {
505    BUILTIN_IMPORTERS
506        .clone()
507        .into_iter()
508        .chain(CUSTOM_IMPORTERS.read().clone())
509}
510
511/// Keeps track of all custom [`Importer`]s.
512///
513/// Use [`register_custom_importer`] to add new importers.
514static CUSTOM_IMPORTERS: LazyLock<parking_lot::RwLock<Vec<Arc<dyn Importer>>>> =
515    LazyLock::new(parking_lot::RwLock::default);
516
517/// Register a custom [`Importer`].
518///
519/// Any time the Rerun Viewer opens a file or directory, this custom importer will be notified.
520/// Refer to [`Importer`]'s documentation for more information.
521#[inline]
522pub fn register_custom_importer(importer: impl Importer + 'static) {
523    CUSTOM_IMPORTERS.write().push(Arc::new(importer));
524}
525
526// ----------------------------------------------------------------------------
527
528/// Empty string if no extension.
529#[inline]
530pub(crate) fn extension(path: &std::path::Path) -> String {
531    path.extension()
532        .unwrap_or_default()
533        .to_ascii_lowercase()
534        .to_string_lossy()
535        .to_string()
536}
537
538// ----------------------------------------------------------------------------
539
540// ...given that all feature flags are turned on for the `image` crate.
541pub const SUPPORTED_IMAGE_EXTENSIONS: &[&str] = &[
542    "avif", "bmp", "dds", "exr", "farbfeld", "ff", "gif", "hdr", "ico", "jpeg", "jpg", "pam",
543    "pbm", "pgm", "png", "ppm", "tga", "tif", "tiff", "webp",
544];
545
546pub const SUPPORTED_DEPTH_IMAGE_EXTENSIONS: &[&str] = &["rvl", "png"];
547
548pub const SUPPORTED_VIDEO_EXTENSIONS: &[&str] = &["mp4"];
549
550pub const SUPPORTED_MESH_EXTENSIONS: &[&str] = &["glb", "gltf", "obj", "stl", "dae"];
551
552// TODO(#4532): `.ply` importer should support 2D point cloud & meshes
553pub const SUPPORTED_POINT_CLOUD_EXTENSIONS: &[&str] = &["ply"];
554
555pub const SUPPORTED_RERUN_EXTENSIONS: &[&str] = &["rbl", "rrd"];
556
557/// 3rd party formats with built-in support.
558pub const SUPPORTED_THIRD_PARTY_FORMATS: &[&str] = &["mcap", "urdf"];
559
560pub const SUPPORTED_PARQUET_EXTENSIONS: &[&str] = &["parquet"];
561
562// TODO(#4555): Add catch-all builtin `Importer` for text files
563pub const SUPPORTED_TEXT_EXTENSIONS: &[&str] = &["txt", "md"];
564
565/// All file extension supported by our builtin [`Importer`]s.
566pub fn supported_extensions() -> impl Iterator<Item = &'static str> {
567    SUPPORTED_RERUN_EXTENSIONS
568        .iter()
569        .chain(SUPPORTED_THIRD_PARTY_FORMATS)
570        .chain(SUPPORTED_IMAGE_EXTENSIONS)
571        .chain(SUPPORTED_DEPTH_IMAGE_EXTENSIONS)
572        .chain(SUPPORTED_VIDEO_EXTENSIONS)
573        .chain(SUPPORTED_MESH_EXTENSIONS)
574        .chain(SUPPORTED_POINT_CLOUD_EXTENSIONS)
575        .chain(SUPPORTED_PARQUET_EXTENSIONS)
576        .chain(SUPPORTED_TEXT_EXTENSIONS)
577        .copied()
578}
579
580/// Is this a supported file extension by any of our builtin [`Importer`]s?
581pub fn is_supported_file_extension(extension: &str) -> bool {
582    re_log::debug_assert!(
583        !extension.starts_with('.'),
584        "Expected extension without period, but got {extension:?}"
585    );
586    let extension = extension.to_lowercase();
587    supported_extensions().any(|ext| ext == extension)
588}
589
590/// Detect the file format from the first bytes of a file (magic bytes).
591///
592/// Returns the file extension (e.g., `"rrd"`, `"mcap"`, `"png"`) if the format is recognized.
593///
594/// Delegates to [`re_sdk_types::components::MediaType::guess_from_data`] which handles
595/// Robotics-specific formats (RRD, MCAP, PLY) and standard formats (PNG, JPEG, GLB, MP4, etc.).
596pub fn detect_format_from_bytes(bytes: &[u8]) -> Option<String> {
597    let media_type = re_sdk_types::components::MediaType::guess_from_data(bytes)?;
598    media_type.file_extension().map(|e| e.to_owned())
599}
600
601/// Map a MIME content type to a file extension.
602///
603/// Returns `None` for types that are too generic to be useful (e.g. `application/octet-stream`)
604/// or for unrecognized types.
605///
606/// Delegates to [`re_sdk_types::components::MediaType::file_extension`].
607pub fn content_type_to_extension(content_type: &str) -> Option<String> {
608    // Take just the MIME type, ignoring parameters like charset
609    let mime = content_type.split(';').next()?.trim();
610
611    // Skip overly generic types
612    if mime == "application/octet-stream" {
613        return None;
614    }
615
616    let media_type = re_sdk_types::components::MediaType(mime.to_owned().into());
617    media_type.file_extension().map(|e| e.to_owned())
618}
619
620#[test]
621fn test_supported_extensions() {
622    assert!(is_supported_file_extension("rrd"));
623    assert!(is_supported_file_extension("mcap"));
624    assert!(is_supported_file_extension("png"));
625    assert!(is_supported_file_extension("urdf"));
626}
627
628#[test]
629fn test_supported_mcap_decoder_identifiers() {
630    let identifiers = supported_mcap_decoder_identifiers(true);
631    let as_strings = identifiers
632        .iter()
633        .map(ToString::to_string)
634        .collect::<Vec<_>>();
635
636    // Check that expected identifiers are present.
637    assert!(as_strings.contains(&FOXGLOVE_LENSES_IDENTIFIER.to_owned()));
638    assert!(as_strings.contains(&URDF_DECODER_IDENTIFIER.to_owned()));
639    assert!(as_strings.contains(&"attachments".to_owned()));
640    assert!(as_strings.contains(&"raw".to_owned()));
641    assert!(as_strings.contains(&"protobuf".to_owned()));
642    assert!(as_strings.contains(&"ros2msg".to_owned()));
643
644    // Check that all identifiers are unique.
645    let unique = as_strings.iter().collect::<std::collections::BTreeSet<_>>();
646    assert_eq!(as_strings.len(), unique.len());
647}
648
649#[test]
650fn test_detect_format_from_bytes() {
651    assert_eq!(
652        detect_format_from_bytes(b"RRF2xxxxx").as_deref(),
653        Some("rrd")
654    );
655    assert_eq!(
656        detect_format_from_bytes(b"RRF0xxxxx").as_deref(),
657        Some("rrd")
658    );
659    assert_eq!(
660        detect_format_from_bytes(&[0x89, 0x4D, 0x43, 0x41, 0x50, 0x30, 0x0D, 0x0A]).as_deref(),
661        Some("mcap")
662    );
663    assert_eq!(
664        detect_format_from_bytes(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).as_deref(),
665        Some("png")
666    );
667    assert_eq!(
668        detect_format_from_bytes(&[0xFF, 0xD8, 0xFF, 0xE0]).as_deref(),
669        Some("jpg")
670    );
671    assert_eq!(
672        detect_format_from_bytes(b"glTFxxxx").as_deref(),
673        Some("glb")
674    );
675    assert_eq!(
676        detect_format_from_bytes(b"ply\nxxx").as_deref(),
677        Some("ply")
678    );
679    assert_eq!(detect_format_from_bytes(b"unknown").as_deref(), None);
680    assert_eq!(detect_format_from_bytes(b"").as_deref(), None);
681}
682
683#[test]
684fn test_content_type_to_extension() {
685    assert_eq!(
686        content_type_to_extension("image/png").as_deref(),
687        Some("png")
688    );
689    assert_eq!(
690        content_type_to_extension("image/png; charset=utf-8").as_deref(),
691        Some("png")
692    );
693    assert_eq!(
694        content_type_to_extension("image/jpeg").as_deref(),
695        Some("jpg")
696    );
697    assert_eq!(
698        content_type_to_extension("video/mp4").as_deref(),
699        Some("mp4")
700    );
701    assert_eq!(
702        content_type_to_extension("model/gltf-binary").as_deref(),
703        Some("glb")
704    );
705    assert_eq!(
706        content_type_to_extension("application/x-rerun").as_deref(),
707        Some("rrd")
708    );
709    assert_eq!(
710        content_type_to_extension("application/octet-stream").as_deref(),
711        None
712    );
713}