Lines
92.22 %
Functions
91.67 %
Branches
100 %
//! Schema-driven projection of typed entity plist replies into display rows.
use std::collections::HashMap;
use nomiscript::{Value, format_value, list_to_vec};
use crate::render::ListRow;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ColumnKind {
Plain,
ParentName,
}
pub struct Column {
pub header: &'static str,
pub key: &'static str,
pub kind: ColumnKind,
pub struct EntitySchema {
pub columns: &'static [Column],
pub static ACCOUNTS: EntitySchema = EntitySchema {
columns: &[
Column {
header: "Name",
key: ":name",
kind: ColumnKind::Plain,
},
header: "Type",
key: ":type",
header: "Parent",
key: ":parent",
kind: ColumnKind::ParentName,
],
};
pub static COMMODITIES: EntitySchema = EntitySchema {
header: "Symbol",
key: ":symbol",
pub static TRANSACTIONS: EntitySchema = EntitySchema {
header: "Date",
key: ":post-date",
header: "Note",
key: ":note",
header: "Amount",
key: ":amount",
/// Return the header label for each column in `schema`.
pub fn headers(schema: &EntitySchema) -> Vec<&'static str> {
schema.columns.iter().map(|c| c.header).collect()
/// Project a reply [`Value`] into schema-aligned [`ListRow`]s.
///
/// Each row has exactly `schema.columns.len()` cells; absent keys produce blank cells.
/// The `:id` field is extracted into `ListRow.id` but never appears as a cell.
pub fn project_rows(value: &Value, schema: &EntitySchema) -> Vec<ListRow> {
match value {
Value::Nil => vec![],
Value::Pair(_) => {
let Some(elements) = list_to_vec(value) else {
return vec![ListRow {
id: None,
cells: vec![format_value(value)],
}];
if elements.is_empty() {
return vec![];
if elements
.first()
.is_some_and(|e| matches!(e, Value::Pair(_)))
{
let id_to_name = build_name_map(&elements);
elements
.iter()
.map(|e| project_element(e, schema, &id_to_name))
.collect()
} else {
let id_to_name = single_name_map(value);
vec![project_element(value, schema, &id_to_name)]
other => vec![ListRow {
cells: vec![format_value(other)],
}],
fn build_name_map(elements: &[Value]) -> HashMap<String, String> {
.filter_map(|e| {
let id = super::row_id(e)?;
let name = crate::eval::plist_field(e, ":name")?;
Some((id, name))
})
fn single_name_map(value: &Value) -> HashMap<String, String> {
super::row_id(value)
.zip(crate::eval::plist_field(value, ":name"))
.into_iter()
fn project_element(
element: &Value,
schema: &EntitySchema,
id_to_name: &HashMap<String, String>,
) -> ListRow {
let id = super::row_id(element);
let cells = schema
.columns
.map(|col| {
let raw = crate::eval::plist_field(element, col.key).unwrap_or_default();
match col.kind {
ColumnKind::Plain => raw,
ColumnKind::ParentName => {
if raw.is_empty() {
String::new()
id_to_name.get(&raw).cloned().unwrap_or_default()
.collect();
ListRow { id, cells }
#[cfg(test)]
mod tests {
use nomiscript::{Fraction, Value};
use super::*;
use crate::render::reparse_list;
fn accounts_list() -> Value {
reparse_list(
r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent "uuid-1"))"#,
)
.expect("accounts list parses")
#[test]
fn project_rows_nil_returns_empty() {
assert_eq!(project_rows(&Value::Nil, &ACCOUNTS), vec![]);
fn project_rows_accounts_cell_count_matches_columns() {
let rows = project_rows(&accounts_list(), &ACCOUNTS);
assert_eq!(rows.len(), 2);
for row in &rows {
assert_eq!(row.cells.len(), ACCOUNTS.columns.len());
fn project_rows_accounts_correct_name_value() {
assert_eq!(rows[0].cells[0], "Checking");
fn project_rows_blank_when_type_key_absent() {
assert_eq!(rows[0].cells[1], "");
fn project_rows_parent_name_resolved_from_list() {
// uuid-2 has :parent "uuid-1" (Checking), ParentName resolves to "Checking"
assert_eq!(rows[1].cells[0], "Savings");
assert_eq!(rows[1].cells[2], "Checking");
fn project_rows_parent_name_blank_for_empty_parent() {
assert_eq!(rows[0].cells[2], "");
fn project_rows_parent_name_blank_for_unknown_parent() {
let value =
reparse_list(r#"((:account :id "x" :name "Orphan" :parent "no-such-uuid"))"#).unwrap();
let rows = project_rows(&value, &ACCOUNTS);
fn project_rows_id_in_row_id_not_in_cells() {
assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
for cell in &rows[0].cells {
assert_ne!(cell, "uuid-1");
fn project_rows_blank_when_key_absent() {
let value = reparse_list(r#"((:account :id "x" :name "NoType"))"#).unwrap();
assert_eq!(rows.len(), 1);
fn project_rows_commodities_schema() {
reparse_list(r#"((:commodity :id "c-1" :symbol "USD" :name "US Dollar"))"#).unwrap();
let rows = project_rows(&value, &COMMODITIES);
assert_eq!(rows[0].cells.len(), COMMODITIES.columns.len());
assert_eq!(rows[0].cells[0], "USD");
assert_eq!(rows[0].cells[1], "US Dollar");
fn project_rows_transactions_schema() {
reparse_list(r#"((:transaction :id "t-1" :note "lunch" :post-date "2026-06-27"))"#)
.unwrap();
let rows = project_rows(&value, &TRANSACTIONS);
assert_eq!(rows[0].cells.len(), TRANSACTIONS.columns.len());
assert_eq!(rows[0].cells[0], "2026-06-27");
assert_eq!(rows[0].cells[1], "lunch");
fn project_rows_scalar_number_fallback() {
let value = Value::Number(Fraction::from_integer(42));
assert_eq!(rows[0].id, None);
assert_eq!(rows[0].cells, vec!["42"]);
fn headers_returns_account_column_headers() {
assert_eq!(headers(&ACCOUNTS), vec!["Name", "Type", "Parent"]);
fn headers_returns_commodity_column_headers() {
assert_eq!(headers(&COMMODITIES), vec!["Symbol", "Name"]);
fn headers_returns_transaction_column_headers() {
assert_eq!(headers(&TRANSACTIONS), vec!["Date", "Note", "Amount"]);
fn project_rows_transactions_amount_extracted_when_present() {
let value = reparse_list(
r#"((:transaction :id "t-1" :note "lunch" :amount "42 USD" :post-date "2026-06-27"))"#,
assert_eq!(rows[0].cells[2], "42 USD");
fn project_rows_accounts_type_extracted_when_present() {
let value = reparse_list(r#"((:account :id "a-1" :name "Bank" :type "asset" :parent ""))"#)
assert_eq!(rows[0].cells[0], "Bank");
assert_eq!(rows[0].cells[1], "asset");