sas7bdat 0.2.0

Rust library + CLI for decoding SAS7BDAT datasets and streaming them to modern formats.
Documentation
use num_traits::ToPrimitive;
use sas7bdat::{CellValue, SasReader, dataset::VariableKind};
use serde::Serialize;
use std::{env, error::Error, path::Path};

#[derive(Debug, Default, Serialize)]
struct NumericSummary {
    count: u64,
    sum: f64,
    min: Option<f64>,
    max: Option<f64>,
}

#[derive(Debug, Serialize)]
struct ColumnSummary {
    index: usize,
    name: String,
    label: Option<String>,
    format: Option<String>,
    kind: &'static str,
    non_missing: u64,
    missing: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    numeric: Option<NumericSummary>,
}

#[derive(Debug, Serialize)]
struct DatasetSummary {
    row_count: u64,
    columns: Vec<ColumnSummary>,
}

const fn variable_kind_name(kind: &VariableKind) -> &'static str {
    match kind {
        VariableKind::Numeric => "numeric",
        VariableKind::Character => "character",
    }
}

fn update_numeric(summary: &mut ColumnSummary, value: f64) {
    if !value.is_finite() {
        summary.missing += 1;
        return;
    }

    summary.non_missing += 1;
    let stats = summary.numeric.get_or_insert_with(NumericSummary::default);
    stats.count += 1;
    stats.sum += value;

    stats.min = Some(stats.min.map_or(value, |current| current.min(value)));
    stats.max = Some(stats.max.map_or(value, |current| current.max(value)));
}

fn main() -> Result<(), Box<dyn Error>> {
    let mut args = env::args_os();
    let program = args
        .next()
        .and_then(|os| os.into_string().ok())
        .unwrap_or_else(|| "summarize".to_owned());
    let Some(path) = args.next() else {
        eprintln!("usage: {program} <path-to-sas7bdat>");
        std::process::exit(2);
    };

    let path = Path::new(&path);
    let mut sas = SasReader::open(path)?;
    let metadata = sas.metadata().clone();

    let mut column_summaries: Vec<ColumnSummary> = metadata
        .variables
        .iter()
        .enumerate()
        .map(|(index, variable)| {
            let kind = variable_kind_name(&variable.kind);
            ColumnSummary {
                index,
                name: variable.name.clone(),
                label: variable.label.clone().filter(|s| !s.trim().is_empty()),
                format: variable
                    .format
                    .as_ref()
                    .map(|fmt| fmt.name.trim().to_owned()),
                kind,
                non_missing: 0,
                missing: 0,
                numeric: if kind == "numeric" {
                    Some(NumericSummary::default())
                } else {
                    None
                },
            }
        })
        .collect();

    let mut total_rows: u64 = 0;
    let mut rows = sas.rows()?;
    while let Some(row) = rows.try_next()? {
        total_rows += 1;

        for (index, value) in row.into_iter().enumerate() {
            let summary = column_summaries
                .get_mut(index)
                .expect("column summary out of bounds");

            match value {
                CellValue::Missing(_) => summary.missing += 1,
                CellValue::Float(actual) => update_numeric(summary, actual),
                CellValue::Int32(actual) => update_numeric(summary, f64::from(actual)),
                CellValue::Int64(actual) => {
                    if let Some(value) = actual.to_f64() {
                        update_numeric(summary, value);
                    } else {
                        summary.missing += 1;
                    }
                }
                // Treat dates/times as non-numeric placeholders for now.
                CellValue::Date(_)
                | CellValue::DateTime(_)
                | CellValue::Time(_)
                | CellValue::NumericString(_)
                | CellValue::Str(_)
                | CellValue::Bytes(_) => {
                    summary.non_missing += 1;
                }
            }
        }
    }

    let summary = DatasetSummary {
        row_count: total_rows,
        columns: column_summaries,
    };

    serde_json::to_writer_pretty(std::io::stdout(), &summary)?;
    println!();
    Ok(())
}