sas7bdat 0.2.0

Rust library + CLI for decoding SAS7BDAT datasets and streaming them to modern formats.
Documentation
use sas7bdat::{CellValue, Error, RowSelection, SasReader};
use sas7bdat_test_support::common;

#[test]
fn rows_windowed_respects_skip_and_limit() {
    let mut sas = open_datetime_fixture();

    let column_count =
        usize::try_from(sas.metadata().column_count).expect("column count fits in usize");

    let options = RowSelection::new().skip_rows(1).max_rows(2);
    let mut rows = sas
        .rows_windowed(&options)
        .expect("failed to build windowed iterator");

    let mut seen = 0usize;
    while let Some(row) = rows.try_next().expect("row iteration failed") {
        assert_eq!(row.len(), column_count, "row should contain every column");
        seen += 1;
    }

    assert_eq!(seen, 2);
    assert!(
        rows.try_next().expect("final advance failed").is_none(),
        "iterator should end after returning the maximum rows"
    );
}

#[test]
fn select_with_supports_name_projection() {
    let mut sas = open_datetime_fixture();

    let metadata = sas.metadata().clone();
    let column_indices = [0usize, 2usize];
    let column_names: Vec<String> = column_indices
        .iter()
        .map(|&idx| metadata.variables[idx].name.trim_end().to_string())
        .collect();

    let first_full_row: Vec<CellValue<'static>> = {
        let mut iter = sas.rows().expect("failed to build full iterator");
        iter.try_next()
            .expect("row iteration failed")
            .expect("expected at least one row")
            .into_iter()
            .map(CellValue::into_owned)
            .collect()
    };

    let options = RowSelection::new()
        .column_names(column_names.clone())
        .max_rows(1);
    let mut rows = sas
        .select_with(&options)
        .expect("failed to build projected iterator");

    let first = rows
        .try_next()
        .expect("row iteration failed")
        .expect("expected first row");
    assert_eq!(first.len(), column_names.len());
    for (value, (&index, name)) in first
        .iter()
        .zip(column_indices.iter().zip(column_names.iter()))
    {
        assert_eq!(
            value, &first_full_row[index],
            "projected value for column '{name}' did not match reference row"
        );
    }
    assert!(
        rows.try_next().expect("final advance failed").is_none(),
        "iterator should respect max_rows limit"
    );
}

#[test]
fn select_with_rejects_duplicate_names() {
    let mut sas = open_datetime_fixture();

    let options = RowSelection::new().column_names(["DATE1", "DATE1"]);
    let Err(err) = sas.select_with(&options) else {
        panic!("expected duplicate projection to fail");
    };
    match err {
        Error::InvalidMetadata { .. } => {}
        other => panic!("expected InvalidMetadata error, got {other:?}"),
    }
}

fn open_datetime_fixture() -> SasReader<std::fs::File> {
    let path = common::fixture_path("fixtures/raw_data/pandas/datetime.sas7bdat");
    SasReader::open(path).expect("failed to open datetime fixture")
}