1use std::cmp::Ordering;
10
11use crate::render::{Cell, Filter, Row, SortSpec};
12
13pub 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
24pub 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
44pub 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
65pub 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
87pub 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
109pub 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 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 assert_eq!(cmp_cells(&num(9), &num(10)), Ordering::Less);
263 assert_eq!(cmp_cells(&text("9"), &num(10)), Ordering::Greater); }
266}