Skip to main content

kaptein_viewmodel/
table.rs

1//! Table-query semantics — the renderer-agnostic sort/filter that every `DataPlane`
2//! implementation shares (ADR-0005).
3//!
4//! This is where "sorting and filtering" live, not in a frontend and not duplicated in
5//! `kaptein-core`. A `DataPlane` maps its rows to `Vec<Row>` + a column-id schema, then
6//! hands the `Query` (sort + filter + window) to these functions. The result is a bounded
7//! `Page`, never a full materialization.
8
9use std::cmp::Ordering;
10
11use crate::render::{Cell, Filter, Row, SortSpec};
12
13/// The display text of a cell, used for substring filtering and as the fallback sort key.
14pub fn cell_text(cell: &Cell) -> String {
15    match cell {
16        Cell::Text { value } => value.clone(),
17        Cell::Number { value } => value.to_string(),
18        Cell::Timestamp { millis } => millis.to_string(),
19        Cell::Status { label_key, .. } => label_key.clone(),
20        Cell::Redacted => String::new(),
21    }
22}
23
24/// Total order across heterogeneous cells. Numbers compare numerically, timestamps
25/// chronologically, everything else lexically by display text.
26pub fn cmp_cells(a: &Cell, b: &Cell) -> Ordering {
27    match (a, b) {
28        (Cell::Number { value: x }, Cell::Number { value: y }) => x.cmp(y),
29        (Cell::Timestamp { millis: x }, Cell::Timestamp { millis: y }) => x.cmp(y),
30        _ => cell_text(a).cmp(&cell_text(b)),
31    }
32}
33
34/// Filter rows by the `Filter` expression, as a case-insensitive substring match over
35/// **every** cell's text. A `None`/empty expression keeps all rows. This is the cheap,
36/// predictable form of the `Filter` contract; the full expression language lands with
37/// the lens engine (Phase 2) — but the *shape* is already a serializable string.
38pub fn filter_rows(rows: Vec<Row>, filter: Option<&Filter>) -> Vec<Row> {
39    let Some(filter) = filter else {
40        return rows;
41    };
42    let needle = filter.expression.trim().to_ascii_lowercase();
43    if needle.is_empty() {
44        return rows;
45    }
46    rows.into_iter()
47        .filter(|row| {
48            row.cells
49                .iter()
50                .any(|c| cell_text(c).to_ascii_lowercase().contains(&needle))
51        })
52        .collect()
53}
54
55/// Sort rows by the given `SortSpec`, resolving `column` against the `column_ids`
56/// schema (column id → cell index). An unknown column leaves order unchanged (stable).
57/// The sort is stable, so equal keys keep their identity order — deterministic across
58/// frontends and in headless/CI.
59pub fn sort_rows(rows: &mut [Row], column_ids: &[String], sort: Option<&SortSpec>) {
60    let Some(sort) = sort else {
61        return;
62    };
63    let Some(idx) = column_ids.iter().position(|id| id == &sort.column) else {
64        return;
65    };
66    rows.sort_by(|a, b| {
67        let ord = match (a.cells.get(idx), b.cells.get(idx)) {
68            (Some(x), Some(y)) => cmp_cells(x, y),
69            (Some(_), None) => Ordering::Greater,
70            (None, Some(_)) => Ordering::Less,
71            (None, None) => Ordering::Equal,
72        };
73        if sort.descending { ord.reverse() } else { ord }
74    });
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use crate::render::{RowId, StatusLevel};
81
82    fn text(v: &str) -> Cell {
83        Cell::Text { value: v.into() }
84    }
85    fn num(v: i64) -> Cell {
86        Cell::Number { value: v }
87    }
88    fn row(id: &str, cells: Vec<Cell>) -> Row {
89        Row {
90            id: RowId(id.into()),
91            cells,
92        }
93    }
94
95    #[test]
96    fn filter_matches_substring_across_cells() {
97        let rows = vec![
98            row("a", vec![text("zebra")]),
99            row("b", vec![text("apple"), text("ns-prod")]),
100            row("c", vec![text("banana")]),
101        ];
102        let f = Filter {
103            expression: "prod".into(),
104        };
105        let out = filter_rows(rows, Some(&f));
106        assert_eq!(out.len(), 1);
107        assert_eq!(out[0].id, RowId("b".into()));
108    }
109
110    #[test]
111    fn filter_none_keeps_all() {
112        let rows = vec![row("a", vec![text("x")]), row("b", vec![text("y")])];
113        assert_eq!(filter_rows(rows.clone(), None), rows);
114    }
115
116    #[test]
117    fn sort_by_numeric_column() {
118        let ids = vec!["name".into(), "count".into()];
119        let mut rows = vec![
120            row("a", vec![text("a"), num(10)]),
121            row("b", vec![text("b"), num(2)]),
122            row("c", vec![text("c"), num(9)]),
123        ];
124        sort_rows(
125            &mut rows,
126            &ids,
127            Some(&SortSpec {
128                column: "count".into(),
129                descending: false,
130            }),
131        );
132        assert_eq!(rows[0].id, RowId("b".into()));
133        assert_eq!(rows[1].id, RowId("c".into()));
134        assert_eq!(rows[2].id, RowId("a".into()));
135    }
136
137    #[test]
138    fn sort_descending_reverses() {
139        let ids = vec!["name".into()];
140        let mut rows = vec![row("a", vec![text("a")]), row("b", vec![text("b")])];
141        sort_rows(
142            &mut rows,
143            &ids,
144            Some(&SortSpec {
145                column: "name".into(),
146                descending: true,
147            }),
148        );
149        assert_eq!(rows[0].id, RowId("b".into()));
150    }
151
152    #[test]
153    fn sort_unknown_column_is_stable_noop() {
154        let ids = vec!["name".into()];
155        let mut rows = vec![row("a", vec![text("a")]), row("b", vec![text("b")])];
156        sort_rows(
157            &mut rows,
158            &ids,
159            Some(&SortSpec {
160                column: "nope".into(),
161                descending: false,
162            }),
163        );
164        assert_eq!(rows[0].id, RowId("a".into()));
165    }
166
167    #[test]
168    fn status_cell_has_label_text() {
169        let cell = Cell::Status {
170            level: StatusLevel::Warning,
171            label_key: "status.running".into(),
172        };
173        assert_eq!(cell_text(&cell), "status.running");
174    }
175}