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.
26///
27/// Same-variant comparisons are **allocation-free** (`&str` comparison, not a `String`
28/// clone): this is the M1.8 hot spot — sorting a 50 000-row `Text` (name) or `Status`
29/// column was cloning two `String`s per comparison (~1.7M allocations/query). The
30/// heterogeneous fallback still goes through `cell_text` (numbers/booleans render as
31/// text), which is rare in practice and cheap relative to the sort.
32pub fn cmp_cells(a: &Cell, b: &Cell) -> Ordering {
33    match (a, b) {
34        (Cell::Number { value: x }, Cell::Number { value: y }) => x.cmp(y),
35        (Cell::Timestamp { millis: x }, Cell::Timestamp { millis: y }) => x.cmp(y),
36        (Cell::Text { value: x }, Cell::Text { value: y }) => x.as_str().cmp(y.as_str()),
37        (Cell::Status { label_key: x, .. }, Cell::Status { label_key: y, .. }) => {
38            x.as_str().cmp(y.as_str())
39        }
40        _ => cell_text(a).cmp(&cell_text(b)),
41    }
42}
43
44/// Filter rows by the `Filter` expression, as a case-insensitive substring match over
45/// **every** cell's text. A `None`/empty expression keeps all rows. This is the cheap,
46/// predictable form of the `Filter` contract; the full expression language lands with
47/// the lens engine (Phase 2) — but the *shape* is already a serializable string.
48pub fn filter_rows(rows: Vec<Row>, filter: Option<&Filter>) -> Vec<Row> {
49    let Some(filter) = filter else {
50        return rows;
51    };
52    let needle = filter.expression.trim().to_ascii_lowercase();
53    if needle.is_empty() {
54        return rows;
55    }
56    rows.into_iter()
57        .filter(|row| {
58            row.cells
59                .iter()
60                .any(|c| cell_text(c).to_ascii_lowercase().contains(&needle))
61        })
62        .collect()
63}
64
65/// Filter a permutation of row indices by the same `Filter` semantics as
66/// [`filter_rows`], without cloning any `Row`. `indices` is the (possibly sorted)
67/// permutation; it is retained in place, dropping indices whose row does not match.
68pub fn filter_indices(indices: Vec<usize>, rows: &[Row], filter: Option<&Filter>) -> Vec<usize> {
69    let Some(filter) = filter else {
70        return indices;
71    };
72    let needle = filter.expression.trim().to_ascii_lowercase();
73    if needle.is_empty() {
74        return indices;
75    }
76    indices
77        .into_iter()
78        .filter(|&i| {
79            rows[i]
80                .cells
81                .iter()
82                .any(|c| cell_text(c).to_ascii_lowercase().contains(&needle))
83        })
84        .collect()
85}
86
87/// Sort rows by the given `SortSpec`, resolving `column` against the `column_ids`
88/// schema (column id → cell index). An unknown column leaves order unchanged (stable).
89/// The sort is stable, so equal keys keep their identity order — deterministic across
90/// frontends and in headless/CI.
91pub fn sort_rows(rows: &mut [Row], column_ids: &[String], sort: Option<&SortSpec>) {
92    let Some(sort) = sort else {
93        return;
94    };
95    let Some(idx) = column_ids.iter().position(|id| id == &sort.column) else {
96        return;
97    };
98    rows.sort_by(|a, b| {
99        let ord = match (a.cells.get(idx), b.cells.get(idx)) {
100            (Some(x), Some(y)) => cmp_cells(x, y),
101            (Some(_), None) => Ordering::Greater,
102            (None, Some(_)) => Ordering::Less,
103            (None, None) => Ordering::Equal,
104        };
105        if sort.descending { ord.reverse() } else { ord }
106    });
107}
108
109/// Sort a permutation of row indices by the same `SortSpec` semantics as [`sort_rows`],
110/// but without cloning any `Row`. This is the allocation-conscious form used by the
111/// informer-backed `MemPlane`: the caller holds `&[Row]` and sorts `indices` into it, so
112/// a 50k-row query sorts 50k `usize`s instead of deep-cloning 50k `Row`s (M1.8).
113///
114/// `indices` must be `0..rows.len()` (any order); after the call it is the stable sort
115/// order of those indices. Same-variant comparison is allocation-free via [`cmp_cells`].
116pub fn sort_indices(
117    indices: &mut [usize],
118    rows: &[Row],
119    column_ids: &[String],
120    sort: Option<&SortSpec>,
121) {
122    let Some(sort) = sort else {
123        return;
124    };
125    let Some(idx) = column_ids.iter().position(|id| id == &sort.column) else {
126        return;
127    };
128    indices.sort_by(|&a, &b| {
129        let ord = match (rows[a].cells.get(idx), rows[b].cells.get(idx)) {
130            (Some(x), Some(y)) => cmp_cells(x, y),
131            (Some(_), None) => Ordering::Greater,
132            (None, Some(_)) => Ordering::Less,
133            (None, None) => Ordering::Equal,
134        };
135        if sort.descending { ord.reverse() } else { ord }
136    });
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::render::{RowId, StatusLevel};
143
144    fn text(v: &str) -> Cell {
145        Cell::Text { value: v.into() }
146    }
147    fn num(v: i64) -> Cell {
148        Cell::Number { value: v }
149    }
150    fn row(id: &str, cells: Vec<Cell>) -> Row {
151        Row {
152            id: RowId(id.into()),
153            cells,
154        }
155    }
156
157    #[test]
158    fn filter_matches_substring_across_cells() {
159        let rows = vec![
160            row("a", vec![text("zebra")]),
161            row("b", vec![text("apple"), text("ns-prod")]),
162            row("c", vec![text("banana")]),
163        ];
164        let f = Filter {
165            expression: "prod".into(),
166        };
167        let out = filter_rows(rows, Some(&f));
168        assert_eq!(out.len(), 1);
169        assert_eq!(out[0].id, RowId("b".into()));
170    }
171
172    #[test]
173    fn filter_none_keeps_all() {
174        let rows = vec![row("a", vec![text("x")]), row("b", vec![text("y")])];
175        assert_eq!(filter_rows(rows.clone(), None), rows);
176    }
177
178    #[test]
179    fn sort_by_numeric_column() {
180        let ids = vec!["name".into(), "count".into()];
181        let mut rows = vec![
182            row("a", vec![text("a"), num(10)]),
183            row("b", vec![text("b"), num(2)]),
184            row("c", vec![text("c"), num(9)]),
185        ];
186        sort_rows(
187            &mut rows,
188            &ids,
189            Some(&SortSpec {
190                column: "count".into(),
191                descending: false,
192            }),
193        );
194        assert_eq!(rows[0].id, RowId("b".into()));
195        assert_eq!(rows[1].id, RowId("c".into()));
196        assert_eq!(rows[2].id, RowId("a".into()));
197    }
198
199    #[test]
200    fn sort_descending_reverses() {
201        let ids = vec!["name".into()];
202        let mut rows = vec![row("a", vec![text("a")]), row("b", vec![text("b")])];
203        sort_rows(
204            &mut rows,
205            &ids,
206            Some(&SortSpec {
207                column: "name".into(),
208                descending: true,
209            }),
210        );
211        assert_eq!(rows[0].id, RowId("b".into()));
212    }
213
214    #[test]
215    fn sort_unknown_column_is_stable_noop() {
216        let ids = vec!["name".into()];
217        let mut rows = vec![row("a", vec![text("a")]), row("b", vec![text("b")])];
218        sort_rows(
219            &mut rows,
220            &ids,
221            Some(&SortSpec {
222                column: "nope".into(),
223                descending: false,
224            }),
225        );
226        assert_eq!(rows[0].id, RowId("a".into()));
227    }
228
229    #[test]
230    fn status_cell_has_label_text() {
231        let cell = Cell::Status {
232            level: StatusLevel::Warning,
233            label_key: "status.running".into(),
234        };
235        assert_eq!(cell_text(&cell), "status.running");
236    }
237
238    #[test]
239    fn cmp_cells_orders_by_text_and_status_without_rendering_numbers() {
240        // M1.8: the common sort keys (name = Text, status = Status) compare lexically.
241        // Same-variant comparisons are allocation-free (`&str` comparison, not a `String`
242        // clone) — the ordering is identical either way, so this asserts the *semantics*
243        // the allocation-free path must preserve.
244        let a = text("apple");
245        let b = text("banana");
246        assert_eq!(cmp_cells(&a, &b), Ordering::Less);
247        assert_eq!(cmp_cells(&b, &a), Ordering::Greater);
248        assert_eq!(cmp_cells(&a, &a), Ordering::Equal);
249
250        let sa = Cell::Status {
251            level: StatusLevel::Ok,
252            label_key: "status.ok".into(),
253        };
254        let sb = Cell::Status {
255            level: StatusLevel::Warning,
256            label_key: "status.warning".into(),
257        };
258        assert_eq!(cmp_cells(&sa, &sb), Ordering::Less);
259        assert_eq!(cmp_cells(&sb, &sa), Ordering::Greater);
260
261        // Numbers still compare numerically, not lexically.
262        assert_eq!(cmp_cells(&num(9), &num(10)), Ordering::Less);
263        // The heterogeneous fallback (Text vs Number) renders both as text.
264        assert_eq!(cmp_cells(&text("9"), &num(10)), Ordering::Greater); // "9" > "10" lexically
265    }
266}