Skip to main content

scirs2_io/
format_detect.rs

1//! Data format detection via magic bytes and content sniffing
2//!
3//! Detects file formats by examining:
4//! 1. Magic bytes (file signature) at the beginning of the file
5//! 2. Content-based heuristics for formats without fixed signatures (CSV, JSON)
6//! 3. File extension as a fallback
7//!
8//! # Supported Formats
9//!
10//! | Format | Magic Bytes | Extension |
11//! |--------|-------------|-----------|
12//! | HDF5 | `\x89HDF\r\n\x1a\n` | `.h5`, `.hdf5` |
13//! | NetCDF Classic | `CDF\x01` or `CDF\x02` | `.nc`, `.cdf` |
14//! | Arrow IPC | `ARROW1` | `.arrow`, `.feather` |
15//! | MATLAB v5 | `MATLAB 5.0` | `.mat` |
16//! | WAV | `RIFF....WAVE` | `.wav` |
17//! | NPY | `\x93NUMPY` | `.npy` |
18//! | NPZ (ZIP) | `PK\x03\x04` | `.npz` |
19//! | PNG | `\x89PNG` | `.png` |
20//! | JPEG | `\xFF\xD8\xFF` | `.jpg`, `.jpeg` |
21//! | BMP | `BM` | `.bmp` |
22//! | TIFF | `II\x2A\x00` or `MM\x00\x2A` | `.tif`, `.tiff` |
23//! | FITS | `SIMPLE` | `.fits`, `.fit` |
24//! | CSV | content-based | `.csv`, `.tsv` |
25//! | JSON | content-based | `.json` |
26//! | Parquet | `PAR1` | `.parquet` |
27//! | FASTA | content-based (`>`) | `.fasta`, `.fa` |
28//! | Matrix Market | `%%MatrixMarket` | `.mtx` |
29//! | ARFF | `@RELATION` | `.arff` |
30//! | MessagePack | content-based | `.msgpack` |
31//!
32//! # Example
33//!
34//! ```rust,no_run
35//! use scirs2_io::format_detect::{detect_format, detect_format_from_bytes, DataFormat};
36//!
37//! // Detect from file
38//! let format = detect_format("data.h5")?;
39//! assert_eq!(format, DataFormat::Hdf5);
40//!
41//! // Detect from bytes
42//! let hdf5_magic = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a];
43//! let format = detect_format_from_bytes(&hdf5_magic, None);
44//! assert_eq!(format, DataFormat::Hdf5);
45//! # Ok::<(), scirs2_io::error::IoError>(())
46//! ```
47
48use crate::error::{IoError, Result};
49use std::path::Path;
50
51/// Recognized scientific data formats
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub enum DataFormat {
54    /// HDF5 (Hierarchical Data Format v5)
55    Hdf5,
56    /// NetCDF Classic (v1 or v2)
57    NetCdf,
58    /// Apache Arrow IPC file format
59    ArrowIpc,
60    /// MATLAB v5 .mat file
61    Matlab,
62    /// WAV audio file
63    Wav,
64    /// NumPy .npy binary array
65    Npy,
66    /// NumPy .npz compressed archive
67    Npz,
68    /// PNG image
69    Png,
70    /// JPEG image
71    Jpeg,
72    /// BMP image
73    Bmp,
74    /// TIFF image
75    Tiff,
76    /// FITS (Flexible Image Transport System)
77    Fits,
78    /// CSV (Comma/Tab-Separated Values)
79    Csv,
80    /// JSON (JavaScript Object Notation)
81    Json,
82    /// Apache Parquet columnar format
83    Parquet,
84    /// FASTA biological sequence format
85    Fasta,
86    /// Matrix Market exchange format
87    MatrixMarket,
88    /// ARFF (Attribute-Relation File Format)
89    Arff,
90    /// MessagePack binary format
91    MessagePack,
92    /// ZIP archive
93    Zip,
94    /// Fortran unformatted sequential file
95    FortranUnformatted,
96    /// SciRS2 custom binary format
97    Scirs2,
98    /// Unknown or unrecognized format
99    Unknown,
100}
101
102impl DataFormat {
103    /// Human-readable name for the format
104    pub fn name(&self) -> &'static str {
105        match self {
106            Self::Hdf5 => "HDF5",
107            Self::NetCdf => "NetCDF",
108            Self::ArrowIpc => "Arrow IPC",
109            Self::Matlab => "MATLAB v5",
110            Self::Wav => "WAV",
111            Self::Npy => "NumPy NPY",
112            Self::Npz => "NumPy NPZ",
113            Self::Png => "PNG",
114            Self::Jpeg => "JPEG",
115            Self::Bmp => "BMP",
116            Self::Tiff => "TIFF",
117            Self::Fits => "FITS",
118            Self::Csv => "CSV",
119            Self::Json => "JSON",
120            Self::Parquet => "Parquet",
121            Self::Fasta => "FASTA",
122            Self::MatrixMarket => "Matrix Market",
123            Self::Arff => "ARFF",
124            Self::MessagePack => "MessagePack",
125            Self::Zip => "ZIP",
126            Self::FortranUnformatted => "Fortran Unformatted",
127            Self::Scirs2 => "SciRS2",
128            Self::Unknown => "Unknown",
129        }
130    }
131
132    /// Common file extension(s) for this format
133    pub fn extensions(&self) -> &'static [&'static str] {
134        match self {
135            Self::Hdf5 => &["h5", "hdf5", "he5"],
136            Self::NetCdf => &["nc", "cdf", "nc3"],
137            Self::ArrowIpc => &["arrow", "feather", "ipc"],
138            Self::Matlab => &["mat"],
139            Self::Wav => &["wav"],
140            Self::Npy => &["npy"],
141            Self::Npz => &["npz"],
142            Self::Png => &["png"],
143            Self::Jpeg => &["jpg", "jpeg"],
144            Self::Bmp => &["bmp"],
145            Self::Tiff => &["tif", "tiff"],
146            Self::Fits => &["fits", "fit", "fts"],
147            Self::Csv => &["csv", "tsv", "txt"],
148            Self::Json => &["json", "geojson"],
149            Self::Parquet => &["parquet"],
150            Self::Fasta => &["fasta", "fa", "fna", "faa"],
151            Self::MatrixMarket => &["mtx"],
152            Self::Arff => &["arff"],
153            Self::MessagePack => &["msgpack", "mpk"],
154            Self::Zip => &["zip"],
155            Self::FortranUnformatted => &["unf"],
156            Self::Scirs2 => &["scirs2"],
157            Self::Unknown => &[],
158        }
159    }
160
161    /// Whether this format is text-based (not binary)
162    pub fn is_text(&self) -> bool {
163        matches!(
164            self,
165            Self::Csv | Self::Json | Self::Fasta | Self::MatrixMarket | Self::Arff
166        )
167    }
168
169    /// Whether this format supports streaming reads
170    pub fn supports_streaming(&self) -> bool {
171        matches!(
172            self,
173            Self::Csv | Self::Json | Self::ArrowIpc | Self::Fasta | Self::Wav | Self::MatrixMarket
174        )
175    }
176}
177
178impl std::fmt::Display for DataFormat {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(f, "{}", self.name())
181    }
182}
183
184/// Result of format detection with confidence
185#[derive(Debug, Clone)]
186pub struct FormatDetection {
187    /// Detected format
188    pub format: DataFormat,
189    /// Confidence level (0.0 = guess, 1.0 = certain)
190    pub confidence: f64,
191    /// How the format was detected
192    pub method: DetectionMethod,
193}
194
195/// How the format was detected
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum DetectionMethod {
198    /// Detected via magic bytes (highest confidence)
199    MagicBytes,
200    /// Detected via content analysis
201    ContentSniffing,
202    /// Detected via file extension (lowest confidence)
203    Extension,
204    /// Multiple methods agreed
205    Combined,
206}
207
208/// Detect the format of a file at the given path.
209///
210/// Reads the first 64 bytes of the file for magic-byte detection,
211/// then falls back to extension-based detection.
212pub fn detect_format<P: AsRef<Path>>(path: P) -> Result<DataFormat> {
213    let path = path.as_ref();
214
215    if !path.exists() {
216        return Err(IoError::FileNotFound(path.display().to_string()));
217    }
218
219    // Read first 256 bytes for sniffing
220    let file = std::fs::File::open(path)
221        .map_err(|e| IoError::FileError(format!("Cannot open '{}': {e}", path.display())))?;
222    let mut reader = std::io::BufReader::new(file);
223    let mut header = vec![0u8; 256];
224    let bytes_read = std::io::Read::read(&mut reader, &mut header)
225        .map_err(|e| IoError::FileError(format!("Cannot read '{}': {e}", path.display())))?;
226    header.truncate(bytes_read);
227
228    let ext = path
229        .extension()
230        .and_then(|e| e.to_str())
231        .map(|e| e.to_lowercase());
232
233    Ok(detect_format_from_bytes(&header, ext.as_deref()))
234}
235
236/// Detect format with detailed confidence information.
237pub fn detect_format_detailed<P: AsRef<Path>>(path: P) -> Result<FormatDetection> {
238    let path = path.as_ref();
239
240    if !path.exists() {
241        return Err(IoError::FileNotFound(path.display().to_string()));
242    }
243
244    let file = std::fs::File::open(path)
245        .map_err(|e| IoError::FileError(format!("Cannot open '{}': {e}", path.display())))?;
246    let mut reader = std::io::BufReader::new(file);
247    let mut header = vec![0u8; 256];
248    let bytes_read = std::io::Read::read(&mut reader, &mut header)
249        .map_err(|e| IoError::FileError(format!("Cannot read '{}': {e}", path.display())))?;
250    header.truncate(bytes_read);
251
252    let ext = path
253        .extension()
254        .and_then(|e| e.to_str())
255        .map(|e| e.to_lowercase());
256
257    Ok(detect_format_detailed_from_bytes(&header, ext.as_deref()))
258}
259
260/// Detect format from raw bytes with optional extension hint.
261///
262/// Checks magic bytes first (highest priority), then content sniffing,
263/// then extension as fallback.
264pub fn detect_format_from_bytes(data: &[u8], extension: Option<&str>) -> DataFormat {
265    // 1. Magic bytes detection
266    if let Some(fmt) = detect_magic_bytes(data) {
267        return fmt;
268    }
269
270    // 2. Content-based sniffing
271    if let Some(fmt) = detect_content(data) {
272        return fmt;
273    }
274
275    // 3. Extension-based fallback
276    if let Some(ext) = extension {
277        if let Some(fmt) = detect_extension(ext) {
278            return fmt;
279        }
280    }
281
282    DataFormat::Unknown
283}
284
285/// Detect format with detailed confidence from bytes.
286pub fn detect_format_detailed_from_bytes(data: &[u8], extension: Option<&str>) -> FormatDetection {
287    // Magic bytes: high confidence
288    if let Some(fmt) = detect_magic_bytes(data) {
289        let ext_agrees = extension
290            .and_then(|ext| detect_extension(ext))
291            .map_or(false, |ef| ef == fmt);
292
293        return FormatDetection {
294            format: fmt,
295            confidence: if ext_agrees { 1.0 } else { 0.95 },
296            method: if ext_agrees {
297                DetectionMethod::Combined
298            } else {
299                DetectionMethod::MagicBytes
300            },
301        };
302    }
303
304    // Content sniffing: medium confidence
305    if let Some(fmt) = detect_content(data) {
306        let ext_agrees = extension
307            .and_then(|ext| detect_extension(ext))
308            .map_or(false, |ef| ef == fmt);
309
310        return FormatDetection {
311            format: fmt,
312            confidence: if ext_agrees { 0.85 } else { 0.6 },
313            method: if ext_agrees {
314                DetectionMethod::Combined
315            } else {
316                DetectionMethod::ContentSniffing
317            },
318        };
319    }
320
321    // Extension only: low confidence
322    if let Some(ext) = extension {
323        if let Some(fmt) = detect_extension(ext) {
324            return FormatDetection {
325                format: fmt,
326                confidence: 0.4,
327                method: DetectionMethod::Extension,
328            };
329        }
330    }
331
332    FormatDetection {
333        format: DataFormat::Unknown,
334        confidence: 0.0,
335        method: DetectionMethod::Extension,
336    }
337}
338
339// =====================================================================
340// Internal detection functions
341// =====================================================================
342
343/// Detect format from magic bytes
344fn detect_magic_bytes(data: &[u8]) -> Option<DataFormat> {
345    if data.len() < 4 {
346        return None;
347    }
348
349    // HDF5: \x89HDF\r\n\x1a\n
350    if data.len() >= 8
351        && data[0] == 0x89
352        && data[1] == 0x48
353        && data[2] == 0x44
354        && data[3] == 0x46
355        && data[4] == 0x0d
356        && data[5] == 0x0a
357        && data[6] == 0x1a
358        && data[7] == 0x0a
359    {
360        return Some(DataFormat::Hdf5);
361    }
362
363    // NetCDF: "CDF\x01" (classic) or "CDF\x02" (64-bit offset)
364    if data.len() >= 4
365        && data[0] == b'C'
366        && data[1] == b'D'
367        && data[2] == b'F'
368        && (data[3] == 0x01 || data[3] == 0x02)
369    {
370        return Some(DataFormat::NetCdf);
371    }
372
373    // Arrow IPC: "ARROW1"
374    if data.len() >= 6 && &data[..6] == b"ARROW1" {
375        return Some(DataFormat::ArrowIpc);
376    }
377
378    // MATLAB v5: starts with "MATLAB 5.0"
379    if data.len() >= 10 && &data[..10] == b"MATLAB 5.0" {
380        return Some(DataFormat::Matlab);
381    }
382
383    // WAV: "RIFF" + 4 bytes + "WAVE"
384    if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WAVE" {
385        return Some(DataFormat::Wav);
386    }
387
388    // NPY: \x93NUMPY
389    if data.len() >= 6 && data[0] == 0x93 && &data[1..6] == b"NUMPY" {
390        return Some(DataFormat::Npy);
391    }
392
393    // Parquet: "PAR1"
394    if data.len() >= 4 && &data[..4] == b"PAR1" {
395        return Some(DataFormat::Parquet);
396    }
397
398    // PNG: \x89PNG\r\n\x1a\n
399    if data.len() >= 8 && data[0] == 0x89 && data[1] == b'P' && data[2] == b'N' && data[3] == b'G' {
400        return Some(DataFormat::Png);
401    }
402
403    // JPEG: \xFF\xD8\xFF
404    if data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
405        return Some(DataFormat::Jpeg);
406    }
407
408    // BMP: "BM"
409    if data.len() >= 2 && data[0] == b'B' && data[1] == b'M' {
410        return Some(DataFormat::Bmp);
411    }
412
413    // TIFF: "II\x2A\x00" (little-endian) or "MM\x00\x2A" (big-endian)
414    if data.len() >= 4
415        && ((data[0] == b'I' && data[1] == b'I' && data[2] == 0x2A && data[3] == 0x00)
416            || (data[0] == b'M' && data[1] == b'M' && data[2] == 0x00 && data[3] == 0x2A))
417    {
418        return Some(DataFormat::Tiff);
419    }
420
421    // FITS: "SIMPLE  ="
422    if data.len() >= 9 && &data[..6] == b"SIMPLE" {
423        return Some(DataFormat::Fits);
424    }
425
426    // ZIP/NPZ: "PK\x03\x04"
427    if data.len() >= 4 && data[0] == b'P' && data[1] == b'K' && data[2] == 0x03 && data[3] == 0x04 {
428        // Could be NPZ if extension says so; default to ZIP
429        return Some(DataFormat::Zip);
430    }
431
432    // SciRS2 custom: "SCIRS2\x00\x01"
433    if data.len() >= 8 && &data[..6] == b"SCIRS2" {
434        return Some(DataFormat::Scirs2);
435    }
436
437    None
438}
439
440/// Detect format from content analysis (for text-based formats)
441fn detect_content(data: &[u8]) -> Option<DataFormat> {
442    // Skip BOM if present
443    let text_data = if data.len() >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
444        &data[3..]
445    } else {
446        data
447    };
448
449    // Try to interpret as UTF-8
450    let text = std::str::from_utf8(text_data).ok()?;
451    let trimmed = text.trim();
452
453    if trimmed.is_empty() {
454        return None;
455    }
456
457    // Matrix Market: starts with "%%MatrixMarket"
458    if trimmed.starts_with("%%MatrixMarket") {
459        return Some(DataFormat::MatrixMarket);
460    }
461
462    // ARFF: starts with "@RELATION" (case-insensitive)
463    let upper = trimmed.to_uppercase();
464    if upper.starts_with("@RELATION") || upper.starts_with("% ") && upper.contains("@RELATION") {
465        return Some(DataFormat::Arff);
466    }
467
468    // JSON: starts with { or [
469    if trimmed.starts_with('{') || trimmed.starts_with('[') {
470        return Some(DataFormat::Json);
471    }
472
473    // FASTA: starts with > followed by sequence header
474    if trimmed.starts_with('>') {
475        let lines: Vec<&str> = trimmed.lines().take(5).collect();
476        if lines.len() >= 2 {
477            let second_line = lines[1].trim();
478            if second_line
479                .chars()
480                .all(|c| "ACGTUNRYWSMKHBVD.-*acgtunrywsmkhbvd".contains(c))
481            {
482                return Some(DataFormat::Fasta);
483            }
484        }
485    }
486
487    // CSV: check for consistent delimited structure
488    if is_likely_csv(trimmed) {
489        return Some(DataFormat::Csv);
490    }
491
492    None
493}
494
495/// Heuristic check for CSV format
496fn is_likely_csv(text: &str) -> bool {
497    let lines: Vec<&str> = text.lines().take(10).collect();
498    if lines.len() < 2 {
499        return false;
500    }
501
502    // Check for consistent delimiter usage
503    for delim in [',', '\t', ';', '|'] {
504        let counts: Vec<usize> = lines
505            .iter()
506            .map(|line| line.matches(delim).count())
507            .collect();
508
509        if counts.is_empty() {
510            continue;
511        }
512
513        let first = counts[0];
514        if first == 0 {
515            continue;
516        }
517
518        // Most lines should have the same count
519        let matching = counts.iter().filter(|&&c| c == first).count();
520        if matching >= counts.len() * 3 / 4 {
521            return true;
522        }
523    }
524
525    false
526}
527
528/// Detect format from file extension
529fn detect_extension(ext: &str) -> Option<DataFormat> {
530    let lower = ext.to_lowercase();
531    // Remove leading dot if present
532    let ext_clean = lower.trim_start_matches('.');
533
534    match ext_clean {
535        "h5" | "hdf5" | "he5" | "hdf" => Some(DataFormat::Hdf5),
536        "nc" | "cdf" | "nc3" | "nc4" => Some(DataFormat::NetCdf),
537        "arrow" | "feather" | "ipc" => Some(DataFormat::ArrowIpc),
538        "mat" => Some(DataFormat::Matlab),
539        "wav" | "wave" => Some(DataFormat::Wav),
540        "npy" => Some(DataFormat::Npy),
541        "npz" => Some(DataFormat::Npz),
542        "png" => Some(DataFormat::Png),
543        "jpg" | "jpeg" => Some(DataFormat::Jpeg),
544        "bmp" => Some(DataFormat::Bmp),
545        "tif" | "tiff" => Some(DataFormat::Tiff),
546        "fits" | "fit" | "fts" => Some(DataFormat::Fits),
547        "csv" | "tsv" => Some(DataFormat::Csv),
548        "json" | "geojson" => Some(DataFormat::Json),
549        "parquet" => Some(DataFormat::Parquet),
550        "fasta" | "fa" | "fna" | "faa" => Some(DataFormat::Fasta),
551        "mtx" => Some(DataFormat::MatrixMarket),
552        "arff" => Some(DataFormat::Arff),
553        "msgpack" | "mpk" => Some(DataFormat::MessagePack),
554        "zip" => Some(DataFormat::Zip),
555        "scirs2" => Some(DataFormat::Scirs2),
556        _ => None,
557    }
558}
559
560// =====================================================================
561// Tests
562// =====================================================================
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn test_hdf5_magic() {
570        let magic = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a];
571        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Hdf5);
572    }
573
574    #[test]
575    fn test_netcdf_magic() {
576        let magic = [b'C', b'D', b'F', 0x01];
577        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::NetCdf);
578
579        let magic2 = [b'C', b'D', b'F', 0x02];
580        assert_eq!(detect_format_from_bytes(&magic2, None), DataFormat::NetCdf);
581    }
582
583    #[test]
584    fn test_arrow_magic() {
585        let magic = b"ARROW1\x00\x00";
586        assert_eq!(detect_format_from_bytes(magic, None), DataFormat::ArrowIpc);
587    }
588
589    #[test]
590    fn test_matlab_magic() {
591        let magic = b"MATLAB 5.0 MAT-file, Platform: WIN64";
592        assert_eq!(detect_format_from_bytes(magic, None), DataFormat::Matlab);
593    }
594
595    #[test]
596    fn test_wav_magic() {
597        let mut magic = Vec::new();
598        magic.extend_from_slice(b"RIFF");
599        magic.extend_from_slice(&1000u32.to_le_bytes());
600        magic.extend_from_slice(b"WAVE");
601        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Wav);
602    }
603
604    #[test]
605    fn test_npy_magic() {
606        let magic = [0x93, b'N', b'U', b'M', b'P', b'Y'];
607        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Npy);
608    }
609
610    #[test]
611    fn test_png_magic() {
612        let magic = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
613        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Png);
614    }
615
616    #[test]
617    fn test_jpeg_magic() {
618        let magic = [0xFF, 0xD8, 0xFF, 0xE0];
619        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Jpeg);
620    }
621
622    #[test]
623    fn test_bmp_magic() {
624        let magic = [b'B', b'M', 0x00, 0x00];
625        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Bmp);
626    }
627
628    #[test]
629    fn test_tiff_magic_le() {
630        let magic = [b'I', b'I', 0x2A, 0x00];
631        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Tiff);
632    }
633
634    #[test]
635    fn test_tiff_magic_be() {
636        let magic = [b'M', b'M', 0x00, 0x2A];
637        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Tiff);
638    }
639
640    #[test]
641    fn test_parquet_magic() {
642        let magic = b"PAR1";
643        assert_eq!(detect_format_from_bytes(magic, None), DataFormat::Parquet);
644    }
645
646    #[test]
647    fn test_zip_magic() {
648        let magic = [b'P', b'K', 0x03, 0x04];
649        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Zip);
650    }
651
652    #[test]
653    fn test_fits_magic() {
654        let magic = b"SIMPLE  =                    T";
655        assert_eq!(detect_format_from_bytes(magic, None), DataFormat::Fits);
656    }
657
658    #[test]
659    fn test_json_content() {
660        let data = b"{\"key\": \"value\"}";
661        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Json);
662
663        let data2 = b"[1, 2, 3]";
664        assert_eq!(detect_format_from_bytes(data2, None), DataFormat::Json);
665    }
666
667    #[test]
668    fn test_matrix_market_content() {
669        let data = b"%%MatrixMarket matrix coordinate real general\n3 3 4\n1 1 1.0\n";
670        assert_eq!(
671            detect_format_from_bytes(data, None),
672            DataFormat::MatrixMarket
673        );
674    }
675
676    #[test]
677    fn test_arff_content() {
678        let data = b"@RELATION test\n@ATTRIBUTE x NUMERIC\n@DATA\n1.0\n";
679        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Arff);
680    }
681
682    #[test]
683    fn test_csv_content() {
684        let data = b"a,b,c\n1,2,3\n4,5,6\n7,8,9\n";
685        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Csv);
686    }
687
688    #[test]
689    fn test_tsv_content() {
690        let data = b"a\tb\tc\n1\t2\t3\n4\t5\t6\n";
691        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Csv);
692    }
693
694    #[test]
695    fn test_fasta_content() {
696        let data = b">seq1\nACGTACGT\n>seq2\nGGGCCCAAA\n";
697        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Fasta);
698    }
699
700    #[test]
701    fn test_extension_fallback() {
702        let empty: &[u8] = &[];
703        assert_eq!(
704            detect_format_from_bytes(empty, Some("h5")),
705            DataFormat::Hdf5
706        );
707        assert_eq!(
708            detect_format_from_bytes(empty, Some("csv")),
709            DataFormat::Csv
710        );
711        assert_eq!(
712            detect_format_from_bytes(empty, Some("json")),
713            DataFormat::Json
714        );
715        assert_eq!(
716            detect_format_from_bytes(empty, Some("mat")),
717            DataFormat::Matlab
718        );
719        assert_eq!(
720            detect_format_from_bytes(empty, Some("parquet")),
721            DataFormat::Parquet
722        );
723    }
724
725    #[test]
726    fn test_unknown_format() {
727        let data = [0x00, 0x01, 0x02, 0x03];
728        assert_eq!(detect_format_from_bytes(&data, None), DataFormat::Unknown);
729    }
730
731    #[test]
732    fn test_format_name() {
733        assert_eq!(DataFormat::Hdf5.name(), "HDF5");
734        assert_eq!(DataFormat::Csv.name(), "CSV");
735        assert_eq!(DataFormat::Unknown.name(), "Unknown");
736    }
737
738    #[test]
739    fn test_format_extensions() {
740        assert!(DataFormat::Hdf5.extensions().contains(&"h5"));
741        assert!(DataFormat::Csv.extensions().contains(&"csv"));
742        assert!(DataFormat::Unknown.extensions().is_empty());
743    }
744
745    #[test]
746    fn test_is_text() {
747        assert!(DataFormat::Csv.is_text());
748        assert!(DataFormat::Json.is_text());
749        assert!(!DataFormat::Hdf5.is_text());
750        assert!(!DataFormat::Png.is_text());
751    }
752
753    #[test]
754    fn test_supports_streaming() {
755        assert!(DataFormat::Csv.supports_streaming());
756        assert!(DataFormat::ArrowIpc.supports_streaming());
757        assert!(!DataFormat::Hdf5.supports_streaming());
758    }
759
760    #[test]
761    fn test_detailed_detection_magic() {
762        let magic = [0x89, 0x48, 0x44, 0x46, 0x0d, 0x0a, 0x1a, 0x0a];
763        let det = detect_format_detailed_from_bytes(&magic, Some("h5"));
764        assert_eq!(det.format, DataFormat::Hdf5);
765        assert!(det.confidence > 0.9);
766        assert_eq!(det.method, DetectionMethod::Combined);
767    }
768
769    #[test]
770    fn test_detailed_detection_content_only() {
771        let data = b"{\"key\": 42}";
772        let det = detect_format_detailed_from_bytes(data, None);
773        assert_eq!(det.format, DataFormat::Json);
774        assert_eq!(det.method, DetectionMethod::ContentSniffing);
775        assert!(det.confidence > 0.5);
776    }
777
778    #[test]
779    fn test_detailed_detection_extension_only() {
780        let data: &[u8] = &[];
781        let det = detect_format_detailed_from_bytes(data, Some("parquet"));
782        assert_eq!(det.format, DataFormat::Parquet);
783        assert_eq!(det.method, DetectionMethod::Extension);
784        assert!(det.confidence < 0.5);
785    }
786
787    #[test]
788    fn test_format_display() {
789        assert_eq!(format!("{}", DataFormat::Hdf5), "HDF5");
790        assert_eq!(format!("{}", DataFormat::ArrowIpc), "Arrow IPC");
791    }
792
793    #[test]
794    fn test_empty_data_with_no_ext() {
795        let data: &[u8] = &[];
796        assert_eq!(detect_format_from_bytes(data, None), DataFormat::Unknown);
797    }
798
799    #[test]
800    fn test_too_short_data() {
801        let data = [0x89];
802        assert_eq!(detect_format_from_bytes(&data, None), DataFormat::Unknown);
803    }
804
805    #[test]
806    fn test_extension_with_dot() {
807        assert_eq!(detect_extension(".h5"), Some(DataFormat::Hdf5));
808        assert_eq!(detect_extension("h5"), Some(DataFormat::Hdf5));
809    }
810
811    #[test]
812    fn test_extension_case_insensitive() {
813        assert_eq!(detect_extension("H5"), Some(DataFormat::Hdf5));
814        assert_eq!(detect_extension("CSV"), Some(DataFormat::Csv));
815        assert_eq!(detect_extension("Json"), Some(DataFormat::Json));
816    }
817
818    #[test]
819    fn test_file_not_found() {
820        let result = detect_format("/definitely/nonexistent/path.h5");
821        assert!(result.is_err());
822    }
823
824    #[test]
825    fn test_scirs2_magic() {
826        let mut magic = Vec::new();
827        magic.extend_from_slice(b"SCIRS2");
828        magic.push(0x00);
829        magic.push(0x01);
830        assert_eq!(detect_format_from_bytes(&magic, None), DataFormat::Scirs2);
831    }
832}