1
//! Schema-driven projection of typed entity plist replies into display rows.
2

            
3
use std::collections::HashMap;
4

            
5
use nomiscript::{Value, format_value, list_to_vec};
6

            
7
use crate::render::ListRow;
8

            
9
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10
pub enum ColumnKind {
11
    Plain,
12
    ParentName,
13
}
14

            
15
pub struct Column {
16
    pub header: &'static str,
17
    pub key: &'static str,
18
    pub kind: ColumnKind,
19
}
20

            
21
pub struct EntitySchema {
22
    pub columns: &'static [Column],
23
}
24

            
25
pub static ACCOUNTS: EntitySchema = EntitySchema {
26
    columns: &[
27
        Column {
28
            header: "Name",
29
            key: ":name",
30
            kind: ColumnKind::Plain,
31
        },
32
        Column {
33
            header: "Type",
34
            key: ":type",
35
            kind: ColumnKind::Plain,
36
        },
37
        Column {
38
            header: "Parent",
39
            key: ":parent",
40
            kind: ColumnKind::ParentName,
41
        },
42
    ],
43
};
44

            
45
pub static COMMODITIES: EntitySchema = EntitySchema {
46
    columns: &[
47
        Column {
48
            header: "Symbol",
49
            key: ":symbol",
50
            kind: ColumnKind::Plain,
51
        },
52
        Column {
53
            header: "Name",
54
            key: ":name",
55
            kind: ColumnKind::Plain,
56
        },
57
    ],
58
};
59

            
60
pub static TRANSACTIONS: EntitySchema = EntitySchema {
61
    columns: &[
62
        Column {
63
            header: "Date",
64
            key: ":post-date",
65
            kind: ColumnKind::Plain,
66
        },
67
        Column {
68
            header: "Note",
69
            key: ":note",
70
            kind: ColumnKind::Plain,
71
        },
72
        Column {
73
            header: "Amount",
74
            key: ":amount",
75
            kind: ColumnKind::Plain,
76
        },
77
    ],
78
};
79

            
80
/// Return the header label for each column in `schema`.
81
3
pub fn headers(schema: &EntitySchema) -> Vec<&'static str> {
82
3
    schema.columns.iter().map(|c| c.header).collect()
83
3
}
84

            
85
/// Project a reply [`Value`] into schema-aligned [`ListRow`]s.
86
///
87
/// Each row has exactly `schema.columns.len()` cells; absent keys produce blank cells.
88
/// The `:id` field is extracted into `ListRow.id` but never appears as a cell.
89
144
pub fn project_rows(value: &Value, schema: &EntitySchema) -> Vec<ListRow> {
90
144
    match value {
91
1
        Value::Nil => vec![],
92
        Value::Pair(_) => {
93
116
            let Some(elements) = list_to_vec(value) else {
94
                return vec![ListRow {
95
                    id: None,
96
                    cells: vec![format_value(value)],
97
                }];
98
            };
99
116
            if elements.is_empty() {
100
                return vec![];
101
116
            }
102
116
            if elements
103
116
                .first()
104
116
                .is_some_and(|e| matches!(e, Value::Pair(_)))
105
            {
106
116
                let id_to_name = build_name_map(&elements);
107
116
                elements
108
116
                    .iter()
109
148
                    .map(|e| project_element(e, schema, &id_to_name))
110
116
                    .collect()
111
            } else {
112
                let id_to_name = single_name_map(value);
113
                vec![project_element(value, schema, &id_to_name)]
114
            }
115
        }
116
27
        other => vec![ListRow {
117
27
            id: None,
118
27
            cells: vec![format_value(other)],
119
27
        }],
120
    }
121
144
}
122

            
123
116
fn build_name_map(elements: &[Value]) -> HashMap<String, String> {
124
116
    elements
125
116
        .iter()
126
148
        .filter_map(|e| {
127
148
            let id = super::row_id(e)?;
128
135
            let name = crate::eval::plist_field(e, ":name")?;
129
94
            Some((id, name))
130
148
        })
131
116
        .collect()
132
116
}
133

            
134
fn single_name_map(value: &Value) -> HashMap<String, String> {
135
    super::row_id(value)
136
        .zip(crate::eval::plist_field(value, ":name"))
137
        .into_iter()
138
        .collect()
139
}
140

            
141
148
fn project_element(
142
148
    element: &Value,
143
148
    schema: &EntitySchema,
144
148
    id_to_name: &HashMap<String, String>,
145
148
) -> ListRow {
146
148
    let id = super::row_id(element);
147
148
    let cells = schema
148
148
        .columns
149
148
        .iter()
150
443
        .map(|col| {
151
443
            let raw = crate::eval::plist_field(element, col.key).unwrap_or_default();
152
443
            match col.kind {
153
337
                ColumnKind::Plain => raw,
154
                ColumnKind::ParentName => {
155
106
                    if raw.is_empty() {
156
99
                        String::new()
157
                    } else {
158
7
                        id_to_name.get(&raw).cloned().unwrap_or_default()
159
                    }
160
                }
161
            }
162
443
        })
163
148
        .collect();
164
148
    ListRow { id, cells }
165
148
}
166

            
167
#[cfg(test)]
168
mod tests {
169
    use nomiscript::{Fraction, Value};
170

            
171
    use super::*;
172
    use crate::render::reparse_list;
173

            
174
6
    fn accounts_list() -> Value {
175
6
        reparse_list(
176
6
            r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent "uuid-1"))"#,
177
        )
178
6
        .expect("accounts list parses")
179
6
    }
180

            
181
    #[test]
182
1
    fn project_rows_nil_returns_empty() {
183
1
        assert_eq!(project_rows(&Value::Nil, &ACCOUNTS), vec![]);
184
1
    }
185

            
186
    #[test]
187
1
    fn project_rows_accounts_cell_count_matches_columns() {
188
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
189
1
        assert_eq!(rows.len(), 2);
190
2
        for row in &rows {
191
2
            assert_eq!(row.cells.len(), ACCOUNTS.columns.len());
192
        }
193
1
    }
194

            
195
    #[test]
196
1
    fn project_rows_accounts_correct_name_value() {
197
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
198
1
        assert_eq!(rows[0].cells[0], "Checking");
199
1
    }
200

            
201
    #[test]
202
1
    fn project_rows_blank_when_type_key_absent() {
203
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
204
1
        assert_eq!(rows[0].cells[1], "");
205
1
    }
206

            
207
    #[test]
208
1
    fn project_rows_parent_name_resolved_from_list() {
209
        // uuid-2 has :parent "uuid-1" (Checking), ParentName resolves to "Checking"
210
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
211
1
        assert_eq!(rows[1].cells[0], "Savings");
212
1
        assert_eq!(rows[1].cells[2], "Checking");
213
1
    }
214

            
215
    #[test]
216
1
    fn project_rows_parent_name_blank_for_empty_parent() {
217
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
218
1
        assert_eq!(rows[0].cells[2], "");
219
1
    }
220

            
221
    #[test]
222
1
    fn project_rows_parent_name_blank_for_unknown_parent() {
223
1
        let value =
224
1
            reparse_list(r#"((:account :id "x" :name "Orphan" :parent "no-such-uuid"))"#).unwrap();
225
1
        let rows = project_rows(&value, &ACCOUNTS);
226
1
        assert_eq!(rows[0].cells[2], "");
227
1
    }
228

            
229
    #[test]
230
1
    fn project_rows_id_in_row_id_not_in_cells() {
231
1
        let rows = project_rows(&accounts_list(), &ACCOUNTS);
232
1
        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
233
3
        for cell in &rows[0].cells {
234
3
            assert_ne!(cell, "uuid-1");
235
        }
236
1
    }
237

            
238
    #[test]
239
1
    fn project_rows_blank_when_key_absent() {
240
1
        let value = reparse_list(r#"((:account :id "x" :name "NoType"))"#).unwrap();
241
1
        let rows = project_rows(&value, &ACCOUNTS);
242
1
        assert_eq!(rows.len(), 1);
243
1
        assert_eq!(rows[0].cells[1], "");
244
1
        assert_eq!(rows[0].cells[2], "");
245
1
    }
246

            
247
    #[test]
248
1
    fn project_rows_commodities_schema() {
249
1
        let value =
250
1
            reparse_list(r#"((:commodity :id "c-1" :symbol "USD" :name "US Dollar"))"#).unwrap();
251
1
        let rows = project_rows(&value, &COMMODITIES);
252
1
        assert_eq!(rows.len(), 1);
253
1
        assert_eq!(rows[0].cells.len(), COMMODITIES.columns.len());
254
1
        assert_eq!(rows[0].cells[0], "USD");
255
1
        assert_eq!(rows[0].cells[1], "US Dollar");
256
1
    }
257

            
258
    #[test]
259
1
    fn project_rows_transactions_schema() {
260
1
        let value =
261
1
            reparse_list(r#"((:transaction :id "t-1" :note "lunch" :post-date "2026-06-27"))"#)
262
1
                .unwrap();
263
1
        let rows = project_rows(&value, &TRANSACTIONS);
264
1
        assert_eq!(rows.len(), 1);
265
1
        assert_eq!(rows[0].cells.len(), TRANSACTIONS.columns.len());
266
1
        assert_eq!(rows[0].cells[0], "2026-06-27");
267
1
        assert_eq!(rows[0].cells[1], "lunch");
268
1
        assert_eq!(rows[0].cells[2], "");
269
1
    }
270

            
271
    #[test]
272
1
    fn project_rows_scalar_number_fallback() {
273
1
        let value = Value::Number(Fraction::from_integer(42));
274
1
        let rows = project_rows(&value, &ACCOUNTS);
275
1
        assert_eq!(rows.len(), 1);
276
1
        assert_eq!(rows[0].id, None);
277
1
        assert_eq!(rows[0].cells, vec!["42"]);
278
1
    }
279

            
280
    #[test]
281
1
    fn headers_returns_account_column_headers() {
282
1
        assert_eq!(headers(&ACCOUNTS), vec!["Name", "Type", "Parent"]);
283
1
    }
284

            
285
    #[test]
286
1
    fn headers_returns_commodity_column_headers() {
287
1
        assert_eq!(headers(&COMMODITIES), vec!["Symbol", "Name"]);
288
1
    }
289

            
290
    #[test]
291
1
    fn headers_returns_transaction_column_headers() {
292
1
        assert_eq!(headers(&TRANSACTIONS), vec!["Date", "Note", "Amount"]);
293
1
    }
294

            
295
    #[test]
296
1
    fn project_rows_transactions_amount_extracted_when_present() {
297
1
        let value = reparse_list(
298
1
            r#"((:transaction :id "t-1" :note "lunch" :amount "42 USD" :post-date "2026-06-27"))"#,
299
        )
300
1
        .unwrap();
301
1
        let rows = project_rows(&value, &TRANSACTIONS);
302
1
        assert_eq!(rows.len(), 1);
303
1
        assert_eq!(rows[0].cells[2], "42 USD");
304
1
    }
305

            
306
    #[test]
307
1
    fn project_rows_accounts_type_extracted_when_present() {
308
1
        let value = reparse_list(r#"((:account :id "a-1" :name "Bank" :type "asset" :parent ""))"#)
309
1
            .unwrap();
310
1
        let rows = project_rows(&value, &ACCOUNTS);
311
1
        assert_eq!(rows.len(), 1);
312
1
        assert_eq!(rows[0].cells[0], "Bank");
313
1
        assert_eq!(rows[0].cells[1], "asset");
314
1
    }
315
}