Skip to main content

readstat/
rs_metadata.rs

1//! File-level and variable-level metadata extracted from `.sas7bdat` files.
2//!
3//! [`ReadStatMetadata`] holds file-level properties (row/variable counts, encoding,
4//! compression, timestamps) and per-variable metadata ([`ReadStatVarMetadata`]) including
5//! names, types, labels, and SAS format strings. After parsing, it builds an Arrow
6//! [`Schema`] that maps SAS types to Arrow data types.
7
8use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
9use log::debug;
10use num_derive::FromPrimitive;
11use serde::Serialize;
12#[cfg(any(not(target_arch = "wasm32"), test))]
13use std::fs::File;
14#[cfg(not(target_arch = "wasm32"))]
15use std::path::Path;
16use std::{
17    collections::{BTreeMap, BTreeSet, HashMap},
18    ffi::{CString, c_void},
19};
20
21use crate::cb::{handle_metadata, handle_variable};
22use crate::err::{ReadStatError, check_c_error};
23use crate::rs_buffer_io::ReadStatBufferCtx;
24use crate::rs_parser::ReadStatParser;
25use crate::rs_path::ReadStatPath;
26use crate::rs_var::{ReadStatVarFormatClass, ReadStatVarType, ReadStatVarTypeClass};
27
28/// File-level metadata extracted from a `.sas7bdat` file.
29///
30/// Populated by the `handle_metadata` and `handle_variable` FFI callbacks during parsing.
31/// After parsing, call [`read_metadata`](ReadStatMetadata::read_metadata) to populate
32/// all fields and build the Arrow [`Schema`].
33#[derive(Clone, Debug, Serialize)]
34pub struct ReadStatMetadata {
35    /// Exact number of rows, or `None` when counting was skipped or ReadStat reports it unknown.
36    pub row_count: Option<i32>,
37    /// Number of variables (columns) in the dataset.
38    pub var_count: i32,
39    /// Internal table name from the SAS file header.
40    pub table_name: String,
41    /// User-assigned file label.
42    pub file_label: String,
43    /// Character encoding of the file (e.g. `"UTF-8"`, `"WINDOWS-1252"`).
44    pub file_encoding: String,
45    /// SAS file format version number.
46    pub version: i32,
47    /// Whether the file uses the 64-bit format (`true`) or 32-bit (`false`).
48    pub is_64bit: bool,
49    /// File creation timestamp (formatted as `YYYY-MM-DD HH:MM:SS`).
50    pub creation_time: String,
51    /// File modification timestamp (formatted as `YYYY-MM-DD HH:MM:SS`).
52    pub modified_time: String,
53    /// Compression method used in the file.
54    pub compression: ReadStatCompress,
55    /// Byte order (endianness) of the file.
56    pub endianness: ReadStatEndian,
57    /// Per-variable metadata, keyed by variable index.
58    pub vars: BTreeMap<i32, ReadStatVarMetadata>,
59    /// Arrow schema derived from variable types. Not serialized.
60    #[serde(skip_serializing)]
61    pub schema: Schema,
62}
63
64impl Default for ReadStatMetadata {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl ReadStatMetadata {
71    /// Serializes this metadata as pretty-printed JSON.
72    pub fn to_json(&self) -> Result<String, ReadStatError> {
73        Ok(serde_json::to_string_pretty(self)?)
74    }
75    /// Creates a new `ReadStatMetadata` with default (empty) values.
76    pub fn new() -> Self {
77        Self {
78            row_count: None,
79            var_count: 0,
80            table_name: String::new(),
81            file_label: String::new(),
82            file_encoding: String::new(),
83            version: 0,
84            is_64bit: false,
85            creation_time: String::new(),
86            modified_time: String::new(),
87            compression: ReadStatCompress::None,
88            endianness: ReadStatEndian::None,
89            vars: BTreeMap::new(),
90            schema: Schema::empty(),
91        }
92    }
93
94    fn initialize_schema(&self) -> Schema {
95        // build up Schema
96        let fields: Vec<Field> = self
97            .vars
98            .values()
99            .map(|vm| {
100                let var_dt = match &vm.var_type {
101                    ReadStatVarType::String
102                    | ReadStatVarType::StringRef
103                    | ReadStatVarType::Unknown => DataType::Utf8,
104                    ReadStatVarType::Int8 | ReadStatVarType::Int16 => DataType::Int16,
105                    ReadStatVarType::Int32 => DataType::Int32,
106                    ReadStatVarType::Float => DataType::Float32,
107                    ReadStatVarType::Double => match &vm.var_format_class {
108                        Some(ReadStatVarFormatClass::Date) => DataType::Date32,
109                        Some(ReadStatVarFormatClass::DateTime) => {
110                            DataType::Timestamp(TimeUnit::Second, None)
111                        }
112                        Some(ReadStatVarFormatClass::DateTimeWithMilliseconds) => {
113                            DataType::Timestamp(TimeUnit::Millisecond, None)
114                        }
115                        Some(ReadStatVarFormatClass::DateTimeWithMicroseconds) => {
116                            DataType::Timestamp(TimeUnit::Microsecond, None)
117                        }
118                        Some(ReadStatVarFormatClass::DateTimeWithNanoseconds) => {
119                            DataType::Timestamp(TimeUnit::Nanosecond, None)
120                        }
121                        Some(ReadStatVarFormatClass::Time) => DataType::Time32(TimeUnit::Second),
122                        Some(ReadStatVarFormatClass::TimeWithMilliseconds) => {
123                            DataType::Time32(TimeUnit::Millisecond)
124                        }
125                        Some(ReadStatVarFormatClass::TimeWithMicroseconds) => {
126                            DataType::Time64(TimeUnit::Microsecond)
127                        }
128                        Some(ReadStatVarFormatClass::TimeWithNanoseconds) => {
129                            DataType::Time64(TimeUnit::Nanosecond)
130                        }
131                        None => DataType::Float64,
132                    },
133                };
134
135                // Build field metadata
136                let mut field = Field::new(&vm.var_name, var_dt, true);
137                let mut metadata = HashMap::new();
138                if !vm.var_label.is_empty() {
139                    metadata.insert("label".to_string(), vm.var_label.clone());
140                }
141                if !vm.var_format.is_empty() {
142                    metadata.insert("sas_format".to_string(), vm.var_format.clone());
143                }
144                metadata.insert("storage_width".to_string(), vm.storage_width.to_string());
145                if vm.display_width != 0 {
146                    metadata.insert("display_width".to_string(), vm.display_width.to_string());
147                }
148                if !metadata.is_empty() {
149                    field = field.with_metadata(metadata);
150                }
151                field
152            })
153            .collect();
154
155        // Add table label as schema metadata if not empty
156        if self.file_label.is_empty() {
157            Schema::new(fields)
158        } else {
159            let mut schema_metadata = HashMap::new();
160            schema_metadata.insert("table_label".to_string(), self.file_label.clone());
161            Schema::new_with_metadata(fields, schema_metadata)
162        }
163    }
164
165    /// Parses metadata from the `.sas7bdat` file referenced by `rsp`.
166    ///
167    /// Sets up the `ReadStat` C parser with metadata and variable handlers, then
168    /// invokes parsing. On success, builds the Arrow [`Schema`] from the
169    /// discovered variable types. If `skip_row_count` is `true`, sets a row
170    /// limit of 1 to skip counting all rows (faster for metadata-only queries).
171    ///
172    /// # Errors
173    ///
174    /// Returns [`ReadStatError`] if FFI parsing fails.
175    #[allow(clippy::cast_possible_wrap, clippy::ptr_as_ptr)]
176    pub fn read_metadata(
177        &mut self,
178        rsp: &ReadStatPath,
179        skip_row_count: bool,
180    ) -> Result<(), ReadStatError> {
181        let mut parsed = Self::new();
182        debug!("Path as C string is {:?}", rsp.cstring_path);
183        let ppath = rsp.cstring_path.as_ptr();
184
185        let ctx = std::ptr::from_mut::<Self>(&mut parsed) as *mut c_void;
186
187        let error: readstat_sys::readstat_error_t = readstat_sys::readstat_error_e_READSTAT_OK;
188        debug!("Initially, error ==> {error}");
189
190        let row_limit = if skip_row_count { Some(1) } else { None };
191
192        let error = ReadStatParser::new()?
193            .set_metadata_handler(Some(handle_metadata))?
194            .set_variable_handler(Some(handle_variable))?
195            .set_row_limit(row_limit)?
196            .parse_sas7bdat(ppath, ctx);
197
198        check_c_error(error as i32)?;
199
200        if skip_row_count {
201            parsed.row_count = None;
202        }
203
204        // if successful, initialize schema
205        parsed.schema = parsed.initialize_schema();
206        *self = parsed;
207        Ok(())
208    }
209
210    /// Parses metadata from an in-memory byte slice containing `.sas7bdat` data.
211    ///
212    /// Equivalent to [`read_metadata`](ReadStatMetadata::read_metadata) but reads from
213    /// a `&[u8]` buffer instead of a file path. Useful for WASM targets, cloud storage,
214    /// HTTP uploads, and testing without filesystem access.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`ReadStatError`] if FFI parsing fails.
219    ///
220    /// # Panics
221    ///
222    /// Panics if the dummy path `CString` allocation fails (should never happen).
223    #[allow(clippy::cast_possible_wrap, clippy::ptr_as_ptr)]
224    pub fn read_metadata_from_bytes(
225        &mut self,
226        bytes: &[u8],
227        skip_row_count: bool,
228    ) -> Result<(), ReadStatError> {
229        let mut parsed = Self::new();
230        let mut buffer_ctx = ReadStatBufferCtx::new(bytes);
231
232        let ctx = std::ptr::from_mut::<Self>(&mut parsed) as *mut c_void;
233
234        let error: readstat_sys::readstat_error_t = readstat_sys::readstat_error_e_READSTAT_OK;
235        debug!("Initially, error ==> {error}");
236
237        let row_limit = if skip_row_count { Some(1) } else { None };
238
239        // Dummy path — custom I/O handlers ignore it
240        let dummy_path = CString::new("").expect("empty string is valid C string");
241
242        let error = buffer_ctx
243            .configure_parser(
244                ReadStatParser::new()?
245                    .set_metadata_handler(Some(handle_metadata))?
246                    .set_variable_handler(Some(handle_variable))?
247                    .set_row_limit(row_limit)?,
248            )?
249            .parse_sas7bdat(dummy_path.as_ptr(), ctx);
250
251        check_c_error(error as i32)?;
252
253        if skip_row_count {
254            parsed.row_count = None;
255        }
256
257        // if successful, initialize schema
258        parsed.schema = parsed.initialize_schema();
259        *self = parsed;
260        Ok(())
261    }
262
263    /// Parses metadata from a memory-mapped `.sas7bdat` file.
264    ///
265    /// Opens the file at `path` and memory-maps it, avoiding explicit read syscalls.
266    /// The OS loads pages on demand and manages caching automatically. This is
267    /// especially beneficial for large files where it avoids copying file data
268    /// through kernel buffers.
269    ///
270    /// # Safety
271    ///
272    /// Memory mapping is safe as long as the file is not modified or truncated by
273    /// another process while the map is active. This is the standard expectation
274    /// for `.sas7bdat` files, which are read-only artifacts.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`ReadStatError`] if the file cannot be opened, mapped, or parsed.
279    #[cfg(not(target_arch = "wasm32"))]
280    pub fn read_metadata_from_mmap(
281        &mut self,
282        path: &Path,
283        skip_row_count: bool,
284    ) -> Result<(), ReadStatError> {
285        let file = File::open(path)?;
286        let mmap = unsafe { memmap2::Mmap::map(&file)? };
287        self.read_metadata_from_bytes(&mmap, skip_row_count)
288    }
289
290    /// Validates column names against the dataset's variables and returns a mapping
291    /// of original variable index to new contiguous index.
292    ///
293    /// Returns `Ok(None)` if `columns` is `None` (no filtering requested).
294    /// Returns `Err(ColumnsNotFound)` if any requested names are not in the dataset.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`ReadStatError::ColumnsNotFound`] if any requested column names
299    /// do not exist in the dataset.
300    pub fn resolve_selected_columns(
301        &self,
302        columns: Option<Vec<String>>,
303    ) -> Result<Option<BTreeMap<i32, i32>>, ReadStatError> {
304        let Some(columns) = columns else {
305            return Ok(None);
306        };
307
308        // Deduplicate while preserving order isn't needed - we use dataset order
309        let requested: BTreeSet<String> = columns.into_iter().collect();
310
311        // Build a name -> index lookup
312        let name_to_index: HashMap<&str, i32> = self
313            .vars
314            .iter()
315            .map(|(&idx, vm)| (vm.var_name.as_str(), idx))
316            .collect();
317
318        // Check for invalid names
319        let not_found: Vec<String> = requested
320            .iter()
321            .filter(|name| !name_to_index.contains_key(name.as_str()))
322            .cloned()
323            .collect();
324
325        if !not_found.is_empty() {
326            let available: Vec<String> = self.vars.values().map(|vm| vm.var_name.clone()).collect();
327            return Err(ReadStatError::ColumnsNotFound {
328                requested: not_found,
329                available,
330            });
331        }
332
333        // Build mapping: original_var_index -> new_contiguous_index
334        // Iterate in original dataset order (BTreeMap is sorted by key)
335        let mut mapping = BTreeMap::new();
336        let mut new_index = 0i32;
337        for (&orig_index, vm) in &self.vars {
338            if requested.contains(&vm.var_name) {
339                mapping.insert(orig_index, new_index);
340                new_index += 1;
341            }
342        }
343
344        Ok(Some(mapping))
345    }
346
347    /// Returns a new `ReadStatMetadata` with only the selected variables,
348    /// re-keyed with contiguous indices starting from 0.
349    ///
350    /// Constructs the result directly instead of cloning the full struct,
351    /// avoiding a deep clone of unselected variables and the original schema.
352    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
353    pub fn filter_to_selected_columns(&self, mapping: &BTreeMap<i32, i32>) -> Self {
354        let new_vars: BTreeMap<i32, ReadStatVarMetadata> = mapping
355            .iter()
356            .filter_map(|(&orig_idx, &new_idx)| {
357                self.vars.get(&orig_idx).map(|vm| (new_idx, vm.clone()))
358            })
359            .collect();
360
361        let mut filtered = Self {
362            row_count: self.row_count,
363            var_count: mapping.len() as i32,
364            table_name: self.table_name.clone(),
365            file_label: self.file_label.clone(),
366            file_encoding: self.file_encoding.clone(),
367            version: self.version,
368            is_64bit: self.is_64bit,
369            creation_time: self.creation_time.clone(),
370            modified_time: self.modified_time.clone(),
371            compression: self.compression.clone(),
372            endianness: self.endianness.clone(),
373            vars: new_vars,
374            schema: Schema::empty(),
375        };
376        filtered.schema = filtered.initialize_schema();
377        filtered
378    }
379}
380
381/// Compression method used in a `.sas7bdat` file.
382///
383/// This enum is `#[non_exhaustive]`: it mirrors a C library enum that may gain
384/// variants. Match with a wildcard arm to remain forward-compatible.
385#[non_exhaustive]
386#[derive(Clone, Debug, Default, FromPrimitive, Serialize)]
387#[allow(clippy::cast_possible_wrap)]
388pub enum ReadStatCompress {
389    /// No compression.
390    #[default]
391    None = readstat_sys::readstat_compress_e_READSTAT_COMPRESS_NONE as isize,
392    /// Row-level (RLE) compression.
393    Rows = readstat_sys::readstat_compress_e_READSTAT_COMPRESS_ROWS as isize,
394    /// Binary (RDC) compression.
395    Binary = readstat_sys::readstat_compress_e_READSTAT_COMPRESS_BINARY as isize,
396}
397
398/// Byte order (endianness) of a `.sas7bdat` file.
399///
400/// This enum is `#[non_exhaustive]`: it mirrors a C library enum that may gain
401/// variants. Match with a wildcard arm to remain forward-compatible.
402#[non_exhaustive]
403#[derive(Clone, Debug, Default, FromPrimitive, Serialize)]
404#[allow(clippy::cast_possible_wrap)]
405pub enum ReadStatEndian {
406    /// Endianness not specified.
407    #[default]
408    None = readstat_sys::readstat_endian_e_READSTAT_ENDIAN_NONE as isize,
409    /// Little-endian byte order.
410    Little = readstat_sys::readstat_endian_e_READSTAT_ENDIAN_LITTLE as isize,
411    /// Big-endian byte order.
412    Big = readstat_sys::readstat_endian_e_READSTAT_ENDIAN_BIG as isize,
413}
414
415/// Metadata for a single variable (column) in a SAS dataset.
416#[derive(Clone, Debug, Serialize)]
417pub struct ReadStatVarMetadata {
418    /// Variable name as defined in the SAS file.
419    pub var_name: String,
420    /// Storage type of the variable.
421    pub var_type: ReadStatVarType,
422    /// High-level type class (string or numeric).
423    pub var_type_class: ReadStatVarTypeClass,
424    /// User-assigned variable label (may be empty).
425    pub var_label: String,
426    /// SAS format string (e.g. `"DATE9"`, `"BEST12"`).
427    pub var_format: String,
428    /// Semantic format class derived from the format string, if date/time-related.
429    pub var_format_class: Option<ReadStatVarFormatClass>,
430    /// Number of bytes used to store the variable value.
431    /// Always 8 for SAS numeric variables; variable for strings.
432    pub storage_width: usize,
433    /// Display width hint from the file. 0 for sas7bdat; populated for XPORT/SPSS.
434    pub display_width: i32,
435}
436
437impl ReadStatVarMetadata {
438    /// Creates a new `ReadStatVarMetadata` with the given field values.
439    #[allow(clippy::too_many_arguments)]
440    pub fn new(
441        var_name: String,
442        var_type: ReadStatVarType,
443        var_type_class: ReadStatVarTypeClass,
444        var_label: String,
445        var_format: String,
446        var_format_class: Option<ReadStatVarFormatClass>,
447        storage_width: usize,
448        display_width: i32,
449    ) -> Self {
450        Self {
451            var_name,
452            var_type,
453            var_type_class,
454            var_label,
455            var_format,
456            var_format_class,
457            storage_width,
458            display_width,
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    /// Create a test metadata instance with the given variable names.
468    #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
469    fn test_metadata(var_names: &[&str]) -> ReadStatMetadata {
470        let mut md = ReadStatMetadata::new();
471        for (i, name) in var_names.iter().enumerate() {
472            md.vars.insert(
473                i as i32,
474                ReadStatVarMetadata::new(
475                    name.to_string(),
476                    ReadStatVarType::Double,
477                    ReadStatVarTypeClass::Numeric,
478                    String::new(),
479                    "BEST12".to_string(),
480                    None,
481                    8,
482                    0,
483                ),
484            );
485        }
486        md.var_count = var_names.len() as i32;
487        md.schema = md.initialize_schema();
488        md
489    }
490
491    // --- resolve_selected_columns ---
492
493    #[test]
494    fn resolve_columns_none_returns_none() {
495        let md = test_metadata(&["a", "b", "c"]);
496        assert!(md.resolve_selected_columns(None).unwrap().is_none());
497    }
498
499    #[test]
500    fn resolve_columns_valid_subset() {
501        let md = test_metadata(&["a", "b", "c"]);
502        let mapping = md
503            .resolve_selected_columns(Some(vec!["a".into(), "c".into()]))
504            .unwrap()
505            .unwrap();
506        assert_eq!(mapping.len(), 2);
507        // "a" is at original index 0, mapped to new index 0
508        assert_eq!(mapping[&0], 0);
509        // "c" is at original index 2, mapped to new index 1
510        assert_eq!(mapping[&2], 1);
511    }
512
513    #[test]
514    fn resolve_columns_invalid_name_errors() {
515        let md = test_metadata(&["a", "b", "c"]);
516        let err = md
517            .resolve_selected_columns(Some(vec!["a".into(), "nonexistent".into()]))
518            .unwrap_err();
519        match err {
520            ReadStatError::ColumnsNotFound {
521                requested,
522                available,
523            } => {
524                assert_eq!(requested, vec!["nonexistent"]);
525                assert_eq!(available, vec!["a", "b", "c"]);
526            }
527            other => panic!("Expected ColumnsNotFound, got {other:?}"),
528        }
529    }
530
531    #[test]
532    fn resolve_columns_all_columns() {
533        let md = test_metadata(&["x", "y", "z"]);
534        let mapping = md
535            .resolve_selected_columns(Some(vec!["x".into(), "y".into(), "z".into()]))
536            .unwrap()
537            .unwrap();
538        assert_eq!(mapping.len(), 3);
539        assert_eq!(mapping[&0], 0);
540        assert_eq!(mapping[&1], 1);
541        assert_eq!(mapping[&2], 2);
542    }
543
544    // --- filter_to_selected_columns ---
545
546    #[test]
547    fn filter_produces_contiguous_indices() {
548        let md = test_metadata(&["a", "b", "c", "d"]);
549        let mapping = md
550            .resolve_selected_columns(Some(vec!["b".into(), "d".into()]))
551            .unwrap()
552            .unwrap();
553        let filtered = md.filter_to_selected_columns(&mapping);
554
555        assert_eq!(filtered.var_count, 2);
556        assert_eq!(filtered.vars[&0].var_name, "b");
557        assert_eq!(filtered.vars[&1].var_name, "d");
558    }
559
560    #[test]
561    fn filter_preserves_schema() {
562        let md = test_metadata(&["a", "b", "c"]);
563        let mapping = md
564            .resolve_selected_columns(Some(vec!["b".into()]))
565            .unwrap()
566            .unwrap();
567        let filtered = md.filter_to_selected_columns(&mapping);
568
569        assert_eq!(filtered.schema.fields().len(), 1);
570        assert_eq!(filtered.schema.fields()[0].name(), "b");
571    }
572
573    // --- initialize_schema ---
574
575    #[test]
576    fn schema_string_type() {
577        let mut md = ReadStatMetadata::new();
578        md.vars.insert(
579            0,
580            ReadStatVarMetadata::new(
581                "name".into(),
582                ReadStatVarType::String,
583                ReadStatVarTypeClass::String,
584                String::new(),
585                "$30".into(),
586                None,
587                30,
588                0,
589            ),
590        );
591        md.var_count = 1;
592        let schema = md.initialize_schema();
593        assert_eq!(*schema.fields()[0].data_type(), DataType::Utf8);
594    }
595
596    #[test]
597    fn schema_float64_type() {
598        let mut md = ReadStatMetadata::new();
599        md.vars.insert(
600            0,
601            ReadStatVarMetadata::new(
602                "value".into(),
603                ReadStatVarType::Double,
604                ReadStatVarTypeClass::Numeric,
605                String::new(),
606                "BEST12".into(),
607                None,
608                8,
609                0,
610            ),
611        );
612        md.var_count = 1;
613        let schema = md.initialize_schema();
614        assert_eq!(*schema.fields()[0].data_type(), DataType::Float64);
615    }
616
617    #[test]
618    fn schema_date_type() {
619        let mut md = ReadStatMetadata::new();
620        md.vars.insert(
621            0,
622            ReadStatVarMetadata::new(
623                "dt".into(),
624                ReadStatVarType::Double,
625                ReadStatVarTypeClass::Numeric,
626                String::new(),
627                "DATE9".into(),
628                Some(ReadStatVarFormatClass::Date),
629                8,
630                0,
631            ),
632        );
633        md.var_count = 1;
634        let schema = md.initialize_schema();
635        assert_eq!(*schema.fields()[0].data_type(), DataType::Date32);
636    }
637
638    #[test]
639    fn schema_datetime_type() {
640        let mut md = ReadStatMetadata::new();
641        md.vars.insert(
642            0,
643            ReadStatVarMetadata::new(
644                "ts".into(),
645                ReadStatVarType::Double,
646                ReadStatVarTypeClass::Numeric,
647                String::new(),
648                "DATETIME22".into(),
649                Some(ReadStatVarFormatClass::DateTime),
650                8,
651                0,
652            ),
653        );
654        md.var_count = 1;
655        let schema = md.initialize_schema();
656        assert_eq!(
657            *schema.fields()[0].data_type(),
658            DataType::Timestamp(TimeUnit::Second, None)
659        );
660    }
661
662    #[test]
663    fn schema_time_type() {
664        let mut md = ReadStatMetadata::new();
665        md.vars.insert(
666            0,
667            ReadStatVarMetadata::new(
668                "tm".into(),
669                ReadStatVarType::Double,
670                ReadStatVarTypeClass::Numeric,
671                String::new(),
672                "TIME8".into(),
673                Some(ReadStatVarFormatClass::Time),
674                8,
675                0,
676            ),
677        );
678        md.var_count = 1;
679        let schema = md.initialize_schema();
680        assert_eq!(
681            *schema.fields()[0].data_type(),
682            DataType::Time32(TimeUnit::Second)
683        );
684    }
685
686    #[test]
687    fn schema_int32_type() {
688        let mut md = ReadStatMetadata::new();
689        md.vars.insert(
690            0,
691            ReadStatVarMetadata::new(
692                "count".into(),
693                ReadStatVarType::Int32,
694                ReadStatVarTypeClass::Numeric,
695                String::new(),
696                String::new(),
697                None,
698                4,
699                0,
700            ),
701        );
702        md.var_count = 1;
703        let schema = md.initialize_schema();
704        assert_eq!(*schema.fields()[0].data_type(), DataType::Int32);
705    }
706
707    #[test]
708    fn schema_with_labels_metadata() {
709        let mut md = ReadStatMetadata::new();
710        md.vars.insert(
711            0,
712            ReadStatVarMetadata::new(
713                "col".into(),
714                ReadStatVarType::Double,
715                ReadStatVarTypeClass::Numeric,
716                "My Label".into(),
717                "BEST12".into(),
718                None,
719                8,
720                0,
721            ),
722        );
723        md.var_count = 1;
724        md.file_label = "My Table".into();
725        let schema = md.initialize_schema();
726
727        // Field metadata
728        let field_meta = schema.fields()[0].metadata();
729        assert_eq!(field_meta.get("label").unwrap(), "My Label");
730
731        // Schema metadata
732        let schema_meta = schema.metadata();
733        assert_eq!(schema_meta.get("table_label").unwrap(), "My Table");
734    }
735
736    #[test]
737    fn schema_no_labels_has_format_and_width_metadata() {
738        let mut md = ReadStatMetadata::new();
739        md.vars.insert(
740            0,
741            ReadStatVarMetadata::new(
742                "col".into(),
743                ReadStatVarType::Double,
744                ReadStatVarTypeClass::Numeric,
745                String::new(),
746                "BEST12".into(),
747                None,
748                8,
749                0,
750            ),
751        );
752        md.var_count = 1;
753        let schema = md.initialize_schema();
754
755        let field_meta = schema.fields()[0].metadata();
756        assert!(!field_meta.contains_key("label"));
757        assert_eq!(field_meta.get("sas_format").unwrap(), "BEST12");
758        assert_eq!(field_meta.get("storage_width").unwrap(), "8");
759        assert!(!field_meta.contains_key("display_width"));
760        assert!(schema.metadata().is_empty());
761    }
762
763    // --- ReadStatMetadata defaults ---
764
765    #[test]
766    fn default_metadata() {
767        let md = ReadStatMetadata::new();
768        assert_eq!(md.row_count, None);
769        assert_eq!(md.var_count, 0);
770        assert!(md.table_name.is_empty());
771        assert!(md.vars.is_empty());
772        assert!(md.schema.fields().is_empty());
773    }
774}