Skip to main content

scirs2_io/
binary_formats.rs

1//! Binary format utilities
2//!
3//! Provides three high-level binary serialisation facilities:
4//!
5//! * **MessagePack** — encode/decode [`serde_json::Value`] trees using `rmp-serde`.
6//! * **[`BinaryArrayFile`]** — store typed numeric arrays with shape metadata in a
7//!   compact binary file (magic bytes + header + raw data).
8//! * **[`ColumnarFile`]** — store a named collection of typed columns
9//!   (`f64`, `i64`, `bool`, `String`) in a single file.
10//!
11//! ## File formats
12//!
13//! ### BinaryArrayFile (`*.baf`)
14//!
15//! ```text
16//! [magic: 8 bytes "SCIRSARR"]
17//! [dtype: 1 byte  (0=f64, 1=i32)]
18//! [ndim: u32 LE]
19//! [dim_0 .. dim_ndim-1: each u64 LE]
20//! [data: ndim*sizeof(T) bytes, little-endian]
21//! ```
22//!
23//! ### ColumnarFile (`*.scircolf`)
24//!
25//! ```text
26//! [magic: 8 bytes "SCIRCOLF"]
27//! [ncols: u32 LE]
28//! per column:
29//!   [name_len: u32 LE][name: UTF-8]
30//!   [type_tag: u8  (0=f64, 1=i64, 2=bool, 3=text)]
31//!   [nrows: u64 LE]
32//!   [data: raw bytes (f64/i64: 8 bytes each LE; bool: 1 byte; text: u32 len + UTF-8)]
33//! ```
34
35use std::collections::HashMap;
36use std::fs::File;
37use std::io::{BufWriter, Read, Write};
38use std::path::Path;
39
40use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
41
42use crate::error::{IoError, Result};
43
44// ─────────────────────────────── MessagePack ─────────────────────────────────
45
46/// Encode a [`serde_json::Value`] as MessagePack bytes.
47pub fn msgpack_encode(value: &serde_json::Value) -> Result<Vec<u8>> {
48    // Convert serde_json::Value to a MessagePack-serialisable form via rmp-serde
49    rmp_serde::to_vec(value)
50        .map_err(|e| IoError::SerializationError(format!("msgpack encode failed: {e}")))
51}
52
53/// Decode MessagePack bytes into a [`serde_json::Value`].
54pub fn msgpack_decode(bytes: &[u8]) -> Result<serde_json::Value> {
55    rmp_serde::from_slice(bytes)
56        .map_err(|e| IoError::DeserializationError(format!("msgpack decode failed: {e}")))
57}
58
59// ─────────────────────────────── BinaryArrayFile ─────────────────────────────
60
61const BAF_MAGIC: &[u8; 8] = b"SCIRSARR";
62const BAF_DTYPE_F64: u8 = 0;
63const BAF_DTYPE_I32: u8 = 1;
64
65/// Binary array file I/O with shape metadata.
66///
67/// All numeric values are stored in little-endian byte order.
68pub struct BinaryArrayFile;
69
70impl BinaryArrayFile {
71    // ── f64 ──────────────────────────────────────────────────────────────────
72
73    /// Write a flat `f64` slice with its `shape` to `path`.
74    ///
75    /// The caller is responsible for ensuring `data.len() == shape.iter().product()`.
76    pub fn write_f64(path: &Path, data: &[f64], shape: &[usize]) -> Result<()> {
77        let expected: usize = shape.iter().product();
78        if data.len() != expected {
79            return Err(IoError::ValidationError(format!(
80                "data length {} does not match shape product {expected}",
81                data.len()
82            )));
83        }
84        let file = File::create(path)
85            .map_err(|e| IoError::FileError(format!("cannot create {:?}: {e}", path)))?;
86        let mut w = BufWriter::new(file);
87        baf_write_header(&mut w, BAF_DTYPE_F64, shape)?;
88        for &v in data {
89            w.write_f64::<LittleEndian>(v)
90                .map_err(|e| IoError::FileError(format!("write f64 failed: {e}")))?;
91        }
92        w.flush()
93            .map_err(|e| IoError::FileError(format!("flush failed: {e}")))
94    }
95
96    /// Read an `f64` array from `path`, returning `(flat_data, shape)`.
97    pub fn read_f64(path: &Path) -> Result<(Vec<f64>, Vec<usize>)> {
98        let mut r = open_for_read(path)?;
99        let (dtype, shape) = baf_read_header(&mut r)?;
100        if dtype != BAF_DTYPE_F64 {
101            return Err(IoError::FormatError(format!(
102                "expected dtype f64 (0), got {dtype}"
103            )));
104        }
105        let n: usize = shape.iter().product();
106        let mut data = Vec::with_capacity(n);
107        for _ in 0..n {
108            data.push(
109                r.read_f64::<LittleEndian>()
110                    .map_err(|e| IoError::FileError(format!("read f64 failed: {e}")))?,
111            );
112        }
113        Ok((data, shape))
114    }
115
116    // ── i32 ──────────────────────────────────────────────────────────────────
117
118    /// Write a flat `i32` slice with its `shape` to `path`.
119    pub fn write_i32(path: &Path, data: &[i32], shape: &[usize]) -> Result<()> {
120        let expected: usize = shape.iter().product();
121        if data.len() != expected {
122            return Err(IoError::ValidationError(format!(
123                "data length {} does not match shape product {expected}",
124                data.len()
125            )));
126        }
127        let file = File::create(path)
128            .map_err(|e| IoError::FileError(format!("cannot create {:?}: {e}", path)))?;
129        let mut w = BufWriter::new(file);
130        baf_write_header(&mut w, BAF_DTYPE_I32, shape)?;
131        for &v in data {
132            w.write_i32::<LittleEndian>(v)
133                .map_err(|e| IoError::FileError(format!("write i32 failed: {e}")))?;
134        }
135        w.flush()
136            .map_err(|e| IoError::FileError(format!("flush failed: {e}")))
137    }
138
139    /// Read an `i32` array from `path`, returning `(flat_data, shape)`.
140    pub fn read_i32(path: &Path) -> Result<(Vec<i32>, Vec<usize>)> {
141        let mut r = open_for_read(path)?;
142        let (dtype, shape) = baf_read_header(&mut r)?;
143        if dtype != BAF_DTYPE_I32 {
144            return Err(IoError::FormatError(format!(
145                "expected dtype i32 (1), got {dtype}"
146            )));
147        }
148        let n: usize = shape.iter().product();
149        let mut data = Vec::with_capacity(n);
150        for _ in 0..n {
151            data.push(
152                r.read_i32::<LittleEndian>()
153                    .map_err(|e| IoError::FileError(format!("read i32 failed: {e}")))?,
154            );
155        }
156        Ok((data, shape))
157    }
158}
159
160// Header helpers
161
162fn baf_write_header<W: Write>(w: &mut W, dtype: u8, shape: &[usize]) -> Result<()> {
163    w.write_all(BAF_MAGIC)
164        .map_err(|e| IoError::FileError(format!("write magic failed: {e}")))?;
165    w.write_u8(dtype)
166        .map_err(|e| IoError::FileError(format!("write dtype failed: {e}")))?;
167    w.write_u32::<LittleEndian>(shape.len() as u32)
168        .map_err(|e| IoError::FileError(format!("write ndim failed: {e}")))?;
169    for &dim in shape {
170        w.write_u64::<LittleEndian>(dim as u64)
171            .map_err(|e| IoError::FileError(format!("write dim failed: {e}")))?;
172    }
173    Ok(())
174}
175
176fn baf_read_header<R: Read>(r: &mut R) -> Result<(u8, Vec<usize>)> {
177    let mut magic = [0u8; 8];
178    r.read_exact(&mut magic)
179        .map_err(|e| IoError::FileError(format!("read magic failed: {e}")))?;
180    if &magic != BAF_MAGIC {
181        return Err(IoError::FormatError(
182            "not a BinaryArrayFile (bad magic bytes)".to_string(),
183        ));
184    }
185    let dtype = r
186        .read_u8()
187        .map_err(|e| IoError::FileError(format!("read dtype failed: {e}")))?;
188    let ndim = r
189        .read_u32::<LittleEndian>()
190        .map_err(|e| IoError::FileError(format!("read ndim failed: {e}")))?;
191    let mut shape = Vec::with_capacity(ndim as usize);
192    for _ in 0..ndim {
193        let d = r
194            .read_u64::<LittleEndian>()
195            .map_err(|e| IoError::FileError(format!("read dim failed: {e}")))?;
196        shape.push(d as usize);
197    }
198    Ok((dtype, shape))
199}
200
201fn open_for_read(path: &Path) -> Result<std::io::BufReader<File>> {
202    let file =
203        File::open(path).map_err(|e| IoError::FileError(format!("cannot open {:?}: {e}", path)))?;
204    Ok(std::io::BufReader::new(file))
205}
206
207// ─────────────────────────────── ColumnarFile ────────────────────────────────
208
209const COLF_MAGIC: &[u8; 8] = b"SCIRCOLF";
210
211const COLF_TAG_F64: u8 = 0;
212const COLF_TAG_I64: u8 = 1;
213const COLF_TAG_BOOL: u8 = 2;
214const COLF_TAG_TEXT: u8 = 3;
215
216/// A single column in a [`ColumnarFile`].
217#[derive(Debug, Clone, PartialEq)]
218pub enum ColumnData {
219    /// 64-bit floating-point column.
220    F64(Vec<f64>),
221    /// 64-bit signed integer column.
222    I64(Vec<i64>),
223    /// Boolean column.
224    Bool(Vec<bool>),
225    /// UTF-8 text column.
226    Text(Vec<String>),
227}
228
229impl ColumnData {
230    /// Number of rows in this column.
231    pub fn len(&self) -> usize {
232        match self {
233            ColumnData::F64(v) => v.len(),
234            ColumnData::I64(v) => v.len(),
235            ColumnData::Bool(v) => v.len(),
236            ColumnData::Text(v) => v.len(),
237        }
238    }
239
240    /// `true` if the column has no rows.
241    pub fn is_empty(&self) -> bool {
242        self.len() == 0
243    }
244}
245
246/// Simple columnar binary file writer / reader.
247///
248/// Columns are stored in insertion order.  Each column is written
249/// independently; the file can be read back selectively by name.
250pub struct ColumnarFile;
251
252impl ColumnarFile {
253    /// Write all columns in `columns` to `path`.
254    pub fn write(path: &Path, columns: &HashMap<String, ColumnData>) -> Result<()> {
255        // Determine a stable column order (sorted by name for reproducibility)
256        let mut names: Vec<&String> = columns.keys().collect();
257        names.sort();
258
259        let file = File::create(path)
260            .map_err(|e| IoError::FileError(format!("cannot create {:?}: {e}", path)))?;
261        let mut w = BufWriter::new(file);
262
263        // Magic
264        w.write_all(COLF_MAGIC)
265            .map_err(|e| IoError::FileError(format!("write magic failed: {e}")))?;
266        // Number of columns
267        w.write_u32::<LittleEndian>(names.len() as u32)
268            .map_err(|e| IoError::FileError(format!("write ncols failed: {e}")))?;
269
270        for name in &names {
271            let col = &columns[*name];
272            colf_write_column(&mut w, name, col)?;
273        }
274
275        w.flush()
276            .map_err(|e| IoError::FileError(format!("flush failed: {e}")))
277    }
278
279    /// Read all columns from `path`.
280    pub fn read(path: &Path) -> Result<HashMap<String, ColumnData>> {
281        let mut r = open_for_read(path)?;
282        colf_read_header(&mut r)?;
283        let ncols = r
284            .read_u32::<LittleEndian>()
285            .map_err(|e| IoError::FileError(format!("read ncols failed: {e}")))?;
286        let mut map = HashMap::with_capacity(ncols as usize);
287        for _ in 0..ncols {
288            let (name, col) = colf_read_column(&mut r)?;
289            map.insert(name, col);
290        }
291        Ok(map)
292    }
293
294    /// Read a single named column from `path`, scanning the file serially.
295    ///
296    /// Returns `Err` if the column is not present.
297    pub fn read_column(path: &Path, col_name: &str) -> Result<ColumnData> {
298        let mut r = open_for_read(path)?;
299        colf_read_header(&mut r)?;
300        let ncols = r
301            .read_u32::<LittleEndian>()
302            .map_err(|e| IoError::FileError(format!("read ncols failed: {e}")))?;
303        for _ in 0..ncols {
304            let (name, col) = colf_read_column(&mut r)?;
305            if name == col_name {
306                return Ok(col);
307            }
308        }
309        Err(IoError::NotFound(format!(
310            "column '{col_name}' not found in {:?}",
311            path
312        )))
313    }
314}
315
316// Column serialisation helpers
317
318fn colf_write_column<W: Write>(w: &mut W, name: &str, col: &ColumnData) -> Result<()> {
319    // Name
320    let name_bytes = name.as_bytes();
321    w.write_u32::<LittleEndian>(name_bytes.len() as u32)
322        .map_err(|e| IoError::FileError(format!("write name_len failed: {e}")))?;
323    w.write_all(name_bytes)
324        .map_err(|e| IoError::FileError(format!("write name failed: {e}")))?;
325
326    // Type tag + nrows
327    let (tag, nrows) = match col {
328        ColumnData::F64(v) => (COLF_TAG_F64, v.len()),
329        ColumnData::I64(v) => (COLF_TAG_I64, v.len()),
330        ColumnData::Bool(v) => (COLF_TAG_BOOL, v.len()),
331        ColumnData::Text(v) => (COLF_TAG_TEXT, v.len()),
332    };
333    w.write_u8(tag)
334        .map_err(|e| IoError::FileError(format!("write tag failed: {e}")))?;
335    w.write_u64::<LittleEndian>(nrows as u64)
336        .map_err(|e| IoError::FileError(format!("write nrows failed: {e}")))?;
337
338    // Data
339    match col {
340        ColumnData::F64(v) => {
341            for &x in v {
342                w.write_f64::<LittleEndian>(x)
343                    .map_err(|e| IoError::FileError(format!("write f64 datum failed: {e}")))?;
344            }
345        }
346        ColumnData::I64(v) => {
347            for &x in v {
348                w.write_i64::<LittleEndian>(x)
349                    .map_err(|e| IoError::FileError(format!("write i64 datum failed: {e}")))?;
350            }
351        }
352        ColumnData::Bool(v) => {
353            for &x in v {
354                w.write_u8(if x { 1 } else { 0 })
355                    .map_err(|e| IoError::FileError(format!("write bool datum failed: {e}")))?;
356            }
357        }
358        ColumnData::Text(v) => {
359            for s in v {
360                let bytes = s.as_bytes();
361                w.write_u32::<LittleEndian>(bytes.len() as u32)
362                    .map_err(|e| IoError::FileError(format!("write text len failed: {e}")))?;
363                w.write_all(bytes)
364                    .map_err(|e| IoError::FileError(format!("write text bytes failed: {e}")))?;
365            }
366        }
367    }
368    Ok(())
369}
370
371fn colf_read_header<R: Read>(r: &mut R) -> Result<()> {
372    let mut magic = [0u8; 8];
373    r.read_exact(&mut magic)
374        .map_err(|e| IoError::FileError(format!("read magic failed: {e}")))?;
375    if &magic != COLF_MAGIC {
376        return Err(IoError::FormatError(
377            "not a ColumnarFile (bad magic bytes)".to_string(),
378        ));
379    }
380    Ok(())
381}
382
383fn colf_read_column<R: Read>(r: &mut R) -> Result<(String, ColumnData)> {
384    // Name
385    let name_len = r
386        .read_u32::<LittleEndian>()
387        .map_err(|e| IoError::FileError(format!("read name_len failed: {e}")))?;
388    let mut name_bytes = vec![0u8; name_len as usize];
389    r.read_exact(&mut name_bytes)
390        .map_err(|e| IoError::FileError(format!("read name bytes failed: {e}")))?;
391    let name = String::from_utf8(name_bytes)
392        .map_err(|e| IoError::FormatError(format!("column name is not valid UTF-8: {e}")))?;
393
394    // Tag + nrows
395    let tag = r
396        .read_u8()
397        .map_err(|e| IoError::FileError(format!("read tag failed: {e}")))?;
398    let nrows = r
399        .read_u64::<LittleEndian>()
400        .map_err(|e| IoError::FileError(format!("read nrows failed: {e}")))?;
401    let nrows = nrows as usize;
402
403    // Data
404    let col = match tag {
405        COLF_TAG_F64 => {
406            let mut v = Vec::with_capacity(nrows);
407            for _ in 0..nrows {
408                v.push(
409                    r.read_f64::<LittleEndian>()
410                        .map_err(|e| IoError::FileError(format!("read f64 datum: {e}")))?,
411                );
412            }
413            ColumnData::F64(v)
414        }
415        COLF_TAG_I64 => {
416            let mut v = Vec::with_capacity(nrows);
417            for _ in 0..nrows {
418                v.push(
419                    r.read_i64::<LittleEndian>()
420                        .map_err(|e| IoError::FileError(format!("read i64 datum: {e}")))?,
421                );
422            }
423            ColumnData::I64(v)
424        }
425        COLF_TAG_BOOL => {
426            let mut v = Vec::with_capacity(nrows);
427            for _ in 0..nrows {
428                let b = r
429                    .read_u8()
430                    .map_err(|e| IoError::FileError(format!("read bool datum: {e}")))?;
431                v.push(b != 0);
432            }
433            ColumnData::Bool(v)
434        }
435        COLF_TAG_TEXT => {
436            let mut v = Vec::with_capacity(nrows);
437            for _ in 0..nrows {
438                let len = r
439                    .read_u32::<LittleEndian>()
440                    .map_err(|e| IoError::FileError(format!("read text len: {e}")))?;
441                let mut buf = vec![0u8; len as usize];
442                r.read_exact(&mut buf)
443                    .map_err(|e| IoError::FileError(format!("read text bytes: {e}")))?;
444                let s = String::from_utf8(buf).map_err(|e| {
445                    IoError::FormatError(format!("text column contains invalid UTF-8: {e}"))
446                })?;
447                v.push(s);
448            }
449            ColumnData::Text(v)
450        }
451        other => {
452            return Err(IoError::FormatError(format!(
453                "unknown column type tag {other}"
454            )))
455        }
456    };
457
458    Ok((name, col))
459}
460
461// ─────────────────────────────── Tests ───────────────────────────────────────
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    // ── MessagePack ───────────────────────────────────────────────────────────
468
469    #[test]
470    fn test_msgpack_encode_decode_object() {
471        let val = serde_json::json!({"name": "Alice", "score": 99, "active": true});
472        let encoded = msgpack_encode(&val).expect("encode");
473        let decoded = msgpack_decode(&encoded).expect("decode");
474        assert_eq!(decoded["name"], "Alice");
475        assert_eq!(decoded["score"], 99);
476        assert_eq!(decoded["active"], true);
477    }
478
479    #[test]
480    fn test_msgpack_encode_decode_array() {
481        let val = serde_json::json!([1, 2, 3, 4, 5]);
482        let encoded = msgpack_encode(&val).expect("encode");
483        let decoded = msgpack_decode(&encoded).expect("decode");
484        assert_eq!(decoded[2], 3);
485    }
486
487    #[test]
488    fn test_msgpack_encode_decode_null() {
489        let val = serde_json::Value::Null;
490        let encoded = msgpack_encode(&val).expect("encode");
491        let decoded = msgpack_decode(&encoded).expect("decode");
492        assert!(decoded.is_null());
493    }
494
495    #[test]
496    fn test_msgpack_decode_invalid_bytes_errors() {
497        let bad = vec![0xc1u8]; // reserved / invalid msgpack byte
498        assert!(msgpack_decode(&bad).is_err());
499    }
500
501    // ── BinaryArrayFile f64 ───────────────────────────────────────────────────
502
503    #[test]
504    fn test_binary_array_file_f64_roundtrip() {
505        let dir = std::env::temp_dir().join("scirs2_io_baf_f64_test");
506        std::fs::create_dir_all(&dir).expect("mkdir");
507        let path = dir.join("array.baf");
508
509        let data: Vec<f64> = (0..12).map(|i| i as f64 * 0.5).collect();
510        let shape = vec![3usize, 4];
511
512        BinaryArrayFile::write_f64(&path, &data, &shape).expect("write f64");
513        let (loaded, loaded_shape) = BinaryArrayFile::read_f64(&path).expect("read f64");
514
515        assert_eq!(loaded_shape, shape);
516        assert_eq!(loaded.len(), 12);
517        for (orig, got) in data.iter().zip(loaded.iter()) {
518            assert!((orig - got).abs() < 1e-12, "mismatch: {orig} vs {got}");
519        }
520
521        let _ = std::fs::remove_dir_all(&dir);
522    }
523
524    #[test]
525    fn test_binary_array_file_f64_1d() {
526        let dir = std::env::temp_dir().join("scirs2_io_baf_f64_1d_test");
527        std::fs::create_dir_all(&dir).expect("mkdir");
528        let path = dir.join("vec.baf");
529
530        let data = vec![1.0f64, 2.0, 3.0, 4.0, 5.0];
531        BinaryArrayFile::write_f64(&path, &data, &[5]).expect("write");
532        let (loaded, shape) = BinaryArrayFile::read_f64(&path).expect("read");
533        assert_eq!(shape, vec![5]);
534        assert_eq!(loaded, data);
535
536        let _ = std::fs::remove_dir_all(&dir);
537    }
538
539    // ── BinaryArrayFile i32 ───────────────────────────────────────────────────
540
541    #[test]
542    fn test_binary_array_file_i32_roundtrip() {
543        let dir = std::env::temp_dir().join("scirs2_io_baf_i32_test");
544        std::fs::create_dir_all(&dir).expect("mkdir");
545        let path = dir.join("labels.baf");
546
547        let data: Vec<i32> = (0..20).collect();
548        let shape = vec![4usize, 5];
549
550        BinaryArrayFile::write_i32(&path, &data, &shape).expect("write i32");
551        let (loaded, loaded_shape) = BinaryArrayFile::read_i32(&path).expect("read i32");
552
553        assert_eq!(loaded_shape, shape);
554        assert_eq!(loaded, data);
555
556        let _ = std::fs::remove_dir_all(&dir);
557    }
558
559    #[test]
560    fn test_binary_array_file_wrong_dtype_error() {
561        let dir = std::env::temp_dir().join("scirs2_io_baf_dtype_err_test");
562        std::fs::create_dir_all(&dir).expect("mkdir");
563        let path = dir.join("typed.baf");
564
565        // Write as f64, try to read as i32
566        BinaryArrayFile::write_f64(&path, &[1.0, 2.0], &[2]).expect("write f64");
567        assert!(BinaryArrayFile::read_i32(&path).is_err());
568
569        let _ = std::fs::remove_dir_all(&dir);
570    }
571
572    // ── ColumnarFile ──────────────────────────────────────────────────────────
573
574    #[test]
575    fn test_columnar_file_all_types_roundtrip() {
576        let dir = std::env::temp_dir().join("scirs2_io_colf_alltype_test");
577        std::fs::create_dir_all(&dir).expect("mkdir");
578        let path = dir.join("cols.scircolf");
579
580        let mut cols: HashMap<String, ColumnData> = HashMap::new();
581        cols.insert(
582            "temps".to_string(),
583            ColumnData::F64(vec![20.5, 21.1, 19.8, 22.3]),
584        );
585        cols.insert(
586            "counts".to_string(),
587            ColumnData::I64(vec![100i64, 200, 300, 400]),
588        );
589        cols.insert(
590            "active".to_string(),
591            ColumnData::Bool(vec![true, false, true, true]),
592        );
593        cols.insert(
594            "labels".to_string(),
595            ColumnData::Text(vec![
596                "alpha".to_string(),
597                "beta".to_string(),
598                "gamma".to_string(),
599                "delta".to_string(),
600            ]),
601        );
602
603        ColumnarFile::write(&path, &cols).expect("write columnar");
604        let loaded = ColumnarFile::read(&path).expect("read columnar");
605
606        assert_eq!(loaded.len(), 4);
607
608        match &loaded["temps"] {
609            ColumnData::F64(v) => {
610                assert!((v[0] - 20.5).abs() < 1e-10);
611                assert!((v[2] - 19.8).abs() < 1e-10);
612            }
613            _ => panic!("expected F64"),
614        }
615        match &loaded["counts"] {
616            ColumnData::I64(v) => assert_eq!(v[1], 200),
617            _ => panic!("expected I64"),
618        }
619        match &loaded["active"] {
620            ColumnData::Bool(v) => {
621                assert!(v[0]);
622                assert!(!v[1]);
623            }
624            _ => panic!("expected Bool"),
625        }
626        match &loaded["labels"] {
627            ColumnData::Text(v) => assert_eq!(v[2], "gamma"),
628            _ => panic!("expected Text"),
629        }
630
631        let _ = std::fs::remove_dir_all(&dir);
632    }
633
634    #[test]
635    fn test_columnar_file_read_single_column() {
636        let dir = std::env::temp_dir().join("scirs2_io_colf_single_col_test");
637        std::fs::create_dir_all(&dir).expect("mkdir");
638        let path = dir.join("single.scircolf");
639
640        let mut cols: HashMap<String, ColumnData> = HashMap::new();
641        cols.insert("x".to_string(), ColumnData::F64(vec![1.1, 2.2, 3.3]));
642        cols.insert("y".to_string(), ColumnData::I64(vec![10, 20, 30]));
643
644        ColumnarFile::write(&path, &cols).expect("write");
645        let y_col = ColumnarFile::read_column(&path, "y").expect("read column y");
646
647        match y_col {
648            ColumnData::I64(v) => assert_eq!(v, vec![10i64, 20, 30]),
649            _ => panic!("expected I64"),
650        }
651
652        // Missing column should error
653        assert!(ColumnarFile::read_column(&path, "z").is_err());
654
655        let _ = std::fs::remove_dir_all(&dir);
656    }
657
658    #[test]
659    fn test_columnar_file_empty_columns() {
660        let dir = std::env::temp_dir().join("scirs2_io_colf_empty_test");
661        std::fs::create_dir_all(&dir).expect("mkdir");
662        let path = dir.join("empty.scircolf");
663
664        let mut cols: HashMap<String, ColumnData> = HashMap::new();
665        cols.insert("e".to_string(), ColumnData::F64(vec![]));
666
667        ColumnarFile::write(&path, &cols).expect("write");
668        let loaded = ColumnarFile::read(&path).expect("read");
669        assert!(loaded["e"].is_empty());
670
671        let _ = std::fs::remove_dir_all(&dir);
672    }
673}