excelx 0.4.0

Type-safe XLSX read/write helpers for Rust structs.
Documentation
use std::io::Cursor;

use calamine::{Reader, Xlsx};
use excelx::{
    CellValue, ColumnDef, ExcelError, ExcelRow, RowView, from_xlsx, to_xlsx, validate_columns,
};

#[derive(Debug, PartialEq)]
struct Person {
    id: i64,
    name: String,
    active: bool,
    score: f64,
}

impl ExcelRow for Person {
    fn columns() -> Vec<ColumnDef> {
        vec![
            ColumnDef::new("name", "Name", 2),
            ColumnDef::new("id", "ID", 1),
            ColumnDef::new("active", "Active", 4),
            ColumnDef::new("score", "Score", 3),
        ]
    }

    fn to_row(&self) -> Vec<CellValue> {
        vec![
            self.name.clone().into(),
            self.id.into(),
            self.active.into(),
            self.score.into(),
        ]
    }

    fn from_row(row: &RowView) -> Result<Self, ExcelError> {
        Ok(Self {
            id: row.required_i64("id")?,
            name: row.required_string("name")?,
            active: row.required_bool("active")?,
            score: row.required_f64("score")?,
        })
    }
}

#[test]
fn round_trips_single_sheet() {
    let people = vec![
        Person {
            id: 1,
            name: "Ada".to_owned(),
            active: true,
            score: 98.5,
        },
        Person {
            id: 2,
            name: "Grace".to_owned(),
            active: false,
            score: 88.0,
        },
    ];

    let bytes = to_xlsx(&people).expect("write workbook");
    let parsed = from_xlsx::<Person>(&bytes).expect("parse workbook");

    assert_eq!(parsed, people);
}

#[test]
fn writes_headers_in_column_order() {
    let bytes = to_xlsx(&[Person {
        id: 1,
        name: "Ada".to_owned(),
        active: true,
        score: 98.5,
    }])
    .expect("write workbook");

    let mut workbook: Xlsx<_> = Xlsx::new(Cursor::new(bytes)).expect("open workbook");
    let range = workbook
        .worksheet_range_at(0)
        .expect("first worksheet")
        .expect("read worksheet");
    let headers: Vec<String> = range
        .rows()
        .next()
        .expect("header row")
        .iter()
        .map(ToString::to_string)
        .collect();

    assert_eq!(headers, ["ID", "Name", "Score", "Active"]);
}

#[test]
fn parses_reordered_headers() {
    #[derive(Debug, PartialEq)]
    struct Reordered(Person);

    impl ExcelRow for Reordered {
        fn columns() -> Vec<ColumnDef> {
            vec![
                ColumnDef::new("name", "Name", 1),
                ColumnDef::new("id", "ID", 2),
                ColumnDef::new("score", "Score", 3),
                ColumnDef::new("active", "Active", 4),
            ]
        }

        fn to_row(&self) -> Vec<CellValue> {
            vec![
                self.0.name.clone().into(),
                self.0.id.into(),
                self.0.score.into(),
                self.0.active.into(),
            ]
        }

        fn from_row(row: &RowView) -> Result<Self, ExcelError> {
            Ok(Self(Person {
                id: row.required_i64("id")?,
                name: row.required_string("name")?,
                active: row.required_bool("active")?,
                score: row.required_f64("score")?,
            }))
        }
    }

    let source = vec![Reordered(Person {
        id: 7,
        name: "Katherine".to_owned(),
        active: true,
        score: 91.25,
    })];
    let bytes = to_xlsx(&source).expect("write workbook");

    let parsed = from_xlsx::<Person>(&bytes).expect("parse with different order");

    assert_eq!(parsed, vec![source.into_iter().next().unwrap().0]);
}

#[test]
fn missing_header_is_deterministic_error() {
    #[derive(Debug)]
    struct MissingHeaderPerson;

    impl ExcelRow for MissingHeaderPerson {
        fn columns() -> Vec<ColumnDef> {
            vec![
                ColumnDef::new("id", "Identifier", 1),
                ColumnDef::new("name", "Name", 2),
            ]
        }

        fn to_row(&self) -> Vec<CellValue> {
            vec![CellValue::Int(1), CellValue::String("Ada".to_owned())]
        }

        fn from_row(_: &RowView) -> Result<Self, ExcelError> {
            Ok(Self)
        }
    }

    let bytes = to_xlsx(&[Person {
        id: 1,
        name: "Ada".to_owned(),
        active: true,
        score: 98.5,
    }])
    .expect("write workbook");

    let error = from_xlsx::<MissingHeaderPerson>(&bytes).expect_err("missing header");

    assert!(matches!(error, ExcelError::MissingHeader(header) if header == "Identifier"));
}

#[test]
fn row_view_reports_type_errors_with_context() {
    #[derive(Debug)]
    struct BadType;

    impl ExcelRow for BadType {
        fn columns() -> Vec<ColumnDef> {
            vec![
                ColumnDef::new("id", "ID", 1),
                ColumnDef::new("name", "Name", 2),
                ColumnDef::new("score", "Score", 3),
                ColumnDef::new("active", "Active", 4),
            ]
        }

        fn to_row(&self) -> Vec<CellValue> {
            vec![
                CellValue::String("not an int".to_owned()),
                CellValue::String("Ada".to_owned()),
                CellValue::Float(98.5),
                CellValue::Bool(true),
            ]
        }

        fn from_row(row: &RowView) -> Result<Self, ExcelError> {
            row.required_i64("id")?;
            Ok(Self)
        }
    }

    let bytes = to_xlsx(&[BadType]).expect("write workbook");
    let error = from_xlsx::<BadType>(&bytes).expect_err("type error");

    assert!(matches!(
        error,
        ExcelError::InvalidCellType {
            row: 2,
            column,
            expected,
            found,
        } if column == "ID" && expected == "integer" && found == "string"
    ));
}

#[test]
fn ignores_empty_rows() {
    #[derive(Debug, PartialEq)]
    struct OptionalPerson {
        id: Option<i64>,
        name: Option<String>,
    }

    impl ExcelRow for OptionalPerson {
        fn columns() -> Vec<ColumnDef> {
            vec![
                ColumnDef::new("id", "ID", 1),
                ColumnDef::new("name", "Name", 2),
            ]
        }

        fn to_row(&self) -> Vec<CellValue> {
            vec![
                self.id.map(CellValue::Int).unwrap_or(CellValue::Empty),
                self.name
                    .clone()
                    .map(CellValue::String)
                    .unwrap_or(CellValue::Empty),
            ]
        }

        fn from_row(row: &RowView) -> Result<Self, ExcelError> {
            Ok(Self {
                id: row.optional_i64("id")?,
                name: row.optional_string("name")?,
            })
        }
    }

    let bytes = to_xlsx(&[
        OptionalPerson {
            id: Some(1),
            name: Some("Ada".to_owned()),
        },
        OptionalPerson {
            id: None,
            name: None,
        },
    ])
    .expect("write workbook");

    let parsed = from_xlsx::<OptionalPerson>(&bytes).expect("parse workbook");

    assert_eq!(
        parsed,
        vec![OptionalPerson {
            id: Some(1),
            name: Some("Ada".to_owned())
        }]
    );
}

#[test]
fn validates_duplicate_order_and_headers() {
    let duplicate_order = vec![
        ColumnDef::new("id", "ID", 1),
        ColumnDef::new("name", "Name", 1),
    ];
    let duplicate_header = vec![
        ColumnDef::new("id", "ID", 1),
        ColumnDef::new("other_id", "ID", 2),
    ];

    assert!(matches!(
        validate_columns(&duplicate_order),
        Err(ExcelError::DuplicateColumnOrder(1))
    ));
    assert!(matches!(
        validate_columns(&duplicate_header),
        Err(ExcelError::DuplicateHeader(header)) if header == "ID"
    ));
}

#[test]
fn malformed_workbook_returns_parse_error() {
    let error = from_xlsx::<Person>(b"not an xlsx file").expect_err("malformed workbook");

    assert!(matches!(error, ExcelError::Parse(_)));
}

#[test]
fn empty_workbook_bytes_return_parse_error() {
    let error = from_xlsx::<Person>(&[]).expect_err("empty workbook bytes");

    assert!(matches!(error, ExcelError::Parse(_)));
}