Skip to main content

rusticity_term/
common.rs

1use chrono::{DateTime, Utc};
2use ratatui::{prelude::*, widgets::*};
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use crate::ui::{filter_area, styles};
7
8pub type ColumnId = &'static str;
9
10static I18N: OnceLock<HashMap<String, String>> = OnceLock::new();
11
12pub fn set_i18n(map: HashMap<String, String>) {
13    I18N.set(map).ok();
14}
15
16pub fn t(key: &str) -> String {
17    I18N.get()
18        .and_then(|map| map.get(key))
19        .cloned()
20        .unwrap_or_else(|| key.to_string())
21}
22
23pub fn translate_column(key: &str, default: &str) -> String {
24    let translated = t(key);
25    if translated == key {
26        default.to_string()
27    } else {
28        translated
29    }
30}
31
32// Width for UTC timestamp format: "YYYY-MM-DD HH:MM:SS (UTC)"
33pub const UTC_TIMESTAMP_WIDTH: u16 = 27;
34
35pub fn format_timestamp(dt: &DateTime<Utc>) -> String {
36    format!("{} (UTC)", dt.format("%Y-%m-%d %H:%M:%S"))
37}
38
39pub fn format_optional_timestamp(dt: Option<DateTime<Utc>>) -> String {
40    dt.map(|t| format_timestamp(&t))
41        .unwrap_or_else(|| "-".to_string())
42}
43
44pub fn format_iso_timestamp(iso_string: &str) -> String {
45    if iso_string.is_empty() {
46        return "-".to_string();
47    }
48
49    // Parse ISO 8601 format (e.g., "2024-01-01T12:30:45.123Z")
50    if let Ok(dt) = DateTime::parse_from_rfc3339(iso_string) {
51        format_timestamp(&dt.with_timezone(&Utc))
52    } else {
53        iso_string.to_string()
54    }
55}
56
57pub fn format_unix_timestamp(unix_string: &str) -> String {
58    if unix_string.is_empty() {
59        return "-".to_string();
60    }
61
62    if let Ok(timestamp) = unix_string.parse::<i64>() {
63        if let Some(dt) = DateTime::from_timestamp(timestamp, 0) {
64            format_timestamp(&dt)
65        } else {
66            unix_string.to_string()
67        }
68    } else {
69        unix_string.to_string()
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum ColumnType {
75    String,
76    Number,
77    DateTime,
78    Boolean,
79}
80
81pub fn format_bytes(bytes: i64) -> String {
82    const KB: i64 = 1000;
83    const MB: i64 = KB * 1000;
84    const GB: i64 = MB * 1000;
85    const TB: i64 = GB * 1000;
86
87    if bytes >= TB {
88        format!("{:.2} TB", bytes as f64 / TB as f64)
89    } else if bytes >= GB {
90        format!("{:.2} GB", bytes as f64 / GB as f64)
91    } else if bytes >= MB {
92        format!("{:.2} MB", bytes as f64 / MB as f64)
93    } else if bytes >= KB {
94        format!("{:.2} KB", bytes as f64 / KB as f64)
95    } else {
96        format!("{} B", bytes)
97    }
98}
99
100pub fn format_memory_mb(mb: i32) -> String {
101    if mb >= 1024 {
102        format!("{} GB", mb / 1024)
103    } else {
104        format!("{} MB", mb)
105    }
106}
107
108pub fn format_duration_seconds(seconds: i32) -> String {
109    if seconds == 0 {
110        return "0s".to_string();
111    }
112
113    let days = seconds / 86400;
114    let hours = (seconds % 86400) / 3600;
115    let minutes = (seconds % 3600) / 60;
116    let secs = seconds % 60;
117
118    let mut parts = Vec::new();
119    if days > 0 {
120        parts.push(format!("{}d", days));
121    }
122    if hours > 0 {
123        parts.push(format!("{}h", hours));
124    }
125    if minutes > 0 {
126        parts.push(format!("{}m", minutes));
127    }
128    if secs > 0 {
129        parts.push(format!("{}s", secs));
130    }
131
132    parts.join(" ")
133}
134
135pub fn border_style(is_active: bool) -> Style {
136    if is_active {
137        styles::active_border()
138    } else {
139        Style::default()
140    }
141}
142
143pub fn render_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
144    if total == 0 {
145        return;
146    }
147    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
148        .begin_symbol(Some("↑"))
149        .end_symbol(Some("↓"));
150    let mut state = ScrollbarState::new(total).position(position);
151    frame.render_stateful_widget(scrollbar, area, &mut state);
152}
153
154pub fn render_vertical_scrollbar(frame: &mut Frame, area: Rect, total: usize, position: usize) {
155    render_scrollbar(frame, area, total, position);
156}
157
158pub fn render_horizontal_scrollbar(frame: &mut Frame, area: Rect, position: usize, total: usize) {
159    let scrollbar = Scrollbar::new(ScrollbarOrientation::HorizontalBottom)
160        .begin_symbol(Some("◀"))
161        .end_symbol(Some("▶"));
162    let mut state = ScrollbarState::new(total).position(position);
163    frame.render_stateful_widget(scrollbar, area, &mut state);
164}
165
166pub fn render_pagination(current: usize, total: usize) -> String {
167    if total == 0 {
168        return "[1]".to_string();
169    }
170    if total <= 10 {
171        return (0..total)
172            .map(|i| {
173                if i == current {
174                    format!("[{}]", i + 1)
175                } else {
176                    format!("{}", i + 1)
177                }
178            })
179            .collect::<Vec<_>>()
180            .join(" ");
181    }
182    let start = current.saturating_sub(4);
183    let end = (start + 9).min(total);
184    let start = if end == total {
185        total.saturating_sub(9)
186    } else {
187        start
188    };
189    (start..end)
190        .map(|i| {
191            if i == current {
192                format!("[{}]", i + 1)
193            } else {
194                format!("{}", i + 1)
195            }
196        })
197        .collect::<Vec<_>>()
198        .join(" ")
199}
200
201/// Renders pagination with unknown total (infinite pagination)
202/// Shows: 1 ... 6 7 8 9 [10] 11 ...
203pub fn render_infinite_pagination(current: usize) -> String {
204    let mut parts = Vec::new();
205
206    // Show 4 pages before current (or from page 1)
207    let start = current.saturating_sub(4);
208
209    // If start > 1, show page 1 and ...
210    if start > 1 {
211        parts.push("1".to_string());
212        parts.push("...".to_string());
213    }
214
215    // Show pages from start to current
216    for i in start..current {
217        parts.push(format!("{}", i + 1));
218    }
219
220    // Current page
221    parts.push(format!("[{}]", current + 1));
222
223    // Show 1 page after current
224    parts.push(format!("{}", current + 2));
225
226    // Always show ... at the end to indicate more pages
227    parts.push("...".to_string());
228
229    parts.join(" ")
230}
231
232pub fn render_pagination_text(current: usize, total: usize) -> String {
233    render_pagination(current, total)
234}
235
236/// Show only navigable, loaded pages, with an unknown remainder. Unlike infinite
237/// pagination, this does not invent a next page that has not been loaded yet.
238pub fn render_partial_pagination(current: usize, loaded_pages: usize) -> String {
239    if loaded_pages == 0 {
240        "...".to_string()
241    } else {
242        format!("{} ...", render_pagination_text(current, loaded_pages))
243    }
244}
245
246pub fn render_dropdown<T: AsRef<str>>(
247    frame: &mut ratatui::Frame,
248    items: &[T],
249    selected_index: usize,
250    filter_area: ratatui::prelude::Rect,
251    controls_after_width: u16,
252) {
253    use ratatui::prelude::*;
254    use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem};
255
256    let max_width = items
257        .iter()
258        .map(|item| item.as_ref().len())
259        .max()
260        .unwrap_or(10) as u16
261        + 4;
262
263    let dropdown_items: Vec<ListItem> = items
264        .iter()
265        .enumerate()
266        .map(|(idx, item)| {
267            let style = if idx == selected_index {
268                Style::default().fg(Color::Yellow).bold()
269            } else {
270                Style::default().fg(Color::White)
271            };
272            ListItem::new(format!(" {} ", item.as_ref())).style(style)
273        })
274        .collect();
275
276    let dropdown_height = dropdown_items.len() as u16 + 2;
277    let dropdown_width = max_width;
278    let dropdown_x = filter_area
279        .x
280        .saturating_add(filter_area.width)
281        .saturating_sub(controls_after_width + dropdown_width);
282
283    // Show every option when there is room below the filter row, so the whole
284    // list is visible while the dropdown is open.
285    let y = filter_area.y + filter_area.height;
286    let space_below = frame.area().height.saturating_sub(y);
287    let dropdown_area = Rect {
288        x: dropdown_x,
289        y,
290        width: dropdown_width,
291        height: dropdown_height.min(space_below.max(3)),
292    };
293
294    // Clear the background first
295    frame.render_widget(Clear, dropdown_area);
296
297    frame.render_widget(
298        List::new(dropdown_items)
299            .block(
300                Block::default()
301                    .borders(Borders::ALL)
302                    .border_type(BorderType::Rounded)
303                    .border_style(Style::default().fg(Color::Yellow)),
304            )
305            .style(Style::default().bg(Color::Black)),
306        dropdown_area,
307    );
308}
309
310pub struct FilterConfig<'a> {
311    pub text: &'a str,
312    pub placeholder: &'a str,
313    pub is_active: bool,
314    pub right_content: Vec<(&'a str, &'a str)>,
315    pub area: Rect,
316}
317
318pub struct FilterAreaConfig<'a> {
319    pub filter_text: &'a str,
320    pub placeholder: &'a str,
321    pub mode: crate::keymap::Mode,
322    pub input_focus: FilterFocusType,
323    pub controls: Vec<FilterControl>,
324    pub area: Rect,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Default)]
328pub enum SortDirection {
329    #[default]
330    Asc,
331    Desc,
332}
333
334impl SortDirection {
335    pub fn as_str(&self) -> &'static str {
336        match self {
337            SortDirection::Asc => "ASC",
338            SortDirection::Desc => "DESC",
339        }
340    }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Default)]
344pub enum InputFocus {
345    #[default]
346    Filter,
347    Pagination,
348    Dropdown(&'static str),
349    Checkbox(&'static str),
350}
351
352impl InputFocus {
353    pub fn next(&self, controls: &[InputFocus]) -> Self {
354        if controls.is_empty() {
355            return *self;
356        }
357        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
358        controls[(idx + 1) % controls.len()]
359    }
360
361    pub fn prev(&self, controls: &[InputFocus]) -> Self {
362        if controls.is_empty() {
363            return *self;
364        }
365        let idx = controls.iter().position(|f| f == self).unwrap_or(0);
366        controls[(idx + controls.len() - 1) % controls.len()]
367    }
368
369    /// Navigate to next page when pagination is focused
370    pub fn handle_page_down(
371        &self,
372        selected: &mut usize,
373        scroll_offset: &mut usize,
374        page_size: usize,
375        filtered_count: usize,
376    ) {
377        if *self == InputFocus::Pagination {
378            // max = start of last page, e.g. 754 items / 50 per page → last page starts at 750
379            let last_page_start = if filtered_count > page_size {
380                ((filtered_count - 1) / page_size) * page_size
381            } else {
382                0
383            };
384            *selected = (*selected + page_size).min(last_page_start);
385            *scroll_offset = *selected;
386        }
387    }
388
389    /// Navigate to previous page when pagination is focused
390    pub fn handle_page_up(
391        &self,
392        selected: &mut usize,
393        scroll_offset: &mut usize,
394        page_size: usize,
395    ) {
396        if *self == InputFocus::Pagination {
397            *selected = selected.saturating_sub(page_size);
398            *scroll_offset = *selected;
399        }
400    }
401}
402
403pub trait CyclicEnum: Copy + PartialEq + Sized + 'static {
404    const ALL: &'static [Self];
405
406    fn next(&self) -> Self {
407        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
408        Self::ALL[(idx + 1) % Self::ALL.len()]
409    }
410
411    fn prev(&self) -> Self {
412        let idx = Self::ALL.iter().position(|x| x == self).unwrap_or(0);
413        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
414    }
415}
416
417#[derive(PartialEq)]
418pub enum FilterFocusType {
419    Input,
420    Control(usize),
421}
422
423pub struct FilterControl {
424    pub text: String,
425    pub is_focused: bool,
426    pub style: ratatui::style::Style,
427}
428
429pub fn render_filter_area(frame: &mut Frame, config: FilterAreaConfig) {
430    use crate::keymap::Mode;
431    use crate::ui::get_cursor;
432    use ratatui::prelude::*;
433
434    let cursor = get_cursor(
435        config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input,
436    );
437    let filter_width = config.area.width.saturating_sub(4) as usize;
438
439    // Calculate controls text
440    let controls_text: String = config
441        .controls
442        .iter()
443        .map(|c| c.text.as_str())
444        .collect::<Vec<_>>()
445        .join(" ⋮ ");
446    let controls_len = controls_text.len();
447
448    let placeholder_len = config.placeholder.len();
449    let content_len =
450        if config.filter_text.is_empty() && config.mode != Mode::FilterInput {
451            placeholder_len
452        } else {
453            config.filter_text.len()
454        } + if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
455            cursor.len()
456        } else {
457            0
458        };
459
460    let available_space = filter_width.saturating_sub(controls_len + 1);
461
462    let mut line_spans = vec![];
463    if config.filter_text.is_empty() {
464        if config.mode == Mode::FilterInput {
465            line_spans.push(Span::raw(""));
466        } else {
467            line_spans.push(Span::styled(
468                config.placeholder,
469                Style::default().fg(Color::DarkGray),
470            ));
471        }
472    } else {
473        line_spans.push(Span::raw(config.filter_text));
474    }
475
476    if config.mode == Mode::FilterInput && config.input_focus == FilterFocusType::Input {
477        line_spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
478    }
479
480    if content_len < available_space {
481        line_spans.push(Span::raw(" ".repeat(available_space - content_len)));
482    }
483
484    if config.mode == Mode::FilterInput {
485        for control in &config.controls {
486            line_spans.push(Span::raw(" ⋮ "));
487            line_spans.push(Span::styled(&control.text, control.style));
488        }
489    } else {
490        line_spans.push(Span::styled(
491            format!(" ⋮ {}", controls_text),
492            Style::default(),
493        ));
494    }
495
496    let filter = filter_area(line_spans, config.mode == Mode::FilterInput);
497    frame.render_widget(filter, config.area);
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use chrono::TimeZone;
504
505    #[test]
506    fn test_format_timestamp() {
507        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
508        assert_eq!(format_timestamp(&dt), "2025-11-12 14:30:45 (UTC)");
509    }
510
511    #[test]
512    fn test_format_optional_timestamp_some() {
513        let dt = Utc.with_ymd_and_hms(2025, 11, 12, 14, 30, 45).unwrap();
514        assert_eq!(
515            format_optional_timestamp(Some(dt)),
516            "2025-11-12 14:30:45 (UTC)"
517        );
518    }
519
520    #[test]
521    fn test_format_optional_timestamp_none() {
522        assert_eq!(format_optional_timestamp(None), "-");
523    }
524
525    #[test]
526    fn test_format_bytes() {
527        assert_eq!(format_bytes(500), "500 B");
528        assert_eq!(format_bytes(1500), "1.50 KB");
529        assert_eq!(format_bytes(1_500_000), "1.50 MB");
530        assert_eq!(format_bytes(1_500_000_000), "1.50 GB");
531        assert_eq!(format_bytes(1_500_000_000_000), "1.50 TB");
532    }
533
534    #[test]
535    fn test_format_duration_seconds_zero() {
536        assert_eq!(format_duration_seconds(0), "0s");
537    }
538
539    #[test]
540    fn test_render_infinite_pagination_page_1() {
541        // Page 1: [1] 2 ...
542        let result = render_infinite_pagination(0);
543        assert_eq!(result, "[1] 2 ...");
544    }
545
546    #[test]
547    fn test_render_infinite_pagination_page_5() {
548        // Page 5: 1 2 3 4 [5] 6 ...
549        let result = render_infinite_pagination(4);
550        assert_eq!(result, "1 2 3 4 [5] 6 ...");
551    }
552
553    #[test]
554    fn test_render_infinite_pagination_page_6() {
555        // Page 6: 2 3 4 5 [6] 7 ... (start=2, no need for "1 ...")
556        let result = render_infinite_pagination(5);
557        assert_eq!(result, "2 3 4 5 [6] 7 ...");
558    }
559
560    #[test]
561    fn test_render_infinite_pagination_page_7() {
562        // Page 7: 1 ... 3 4 5 6 [7] 8 ...
563        let result = render_infinite_pagination(6);
564        assert_eq!(result, "1 ... 3 4 5 6 [7] 8 ...");
565    }
566
567    #[test]
568    fn test_render_infinite_pagination_page_10() {
569        // Page 10: 1 ... 6 7 8 9 [10] 11 ...
570        let result = render_infinite_pagination(9);
571        assert_eq!(result, "1 ... 6 7 8 9 [10] 11 ...");
572    }
573
574    #[test]
575    fn test_render_infinite_pagination_page_100() {
576        // Page 100: 1 ... 96 97 98 99 [100] 101 ...
577        let result = render_infinite_pagination(99);
578        assert_eq!(result, "1 ... 96 97 98 99 [100] 101 ...");
579    }
580
581    #[test]
582    fn test_format_duration_seconds_only_seconds() {
583        assert_eq!(format_duration_seconds(30), "30s");
584    }
585
586    #[test]
587    fn test_format_duration_seconds_minutes_and_seconds() {
588        assert_eq!(format_duration_seconds(120), "2m");
589        assert_eq!(format_duration_seconds(150), "2m 30s");
590    }
591
592    #[test]
593    fn test_format_duration_seconds_hours() {
594        assert_eq!(format_duration_seconds(3630), "1h 30s");
595        assert_eq!(format_duration_seconds(10800), "3h");
596    }
597
598    #[test]
599    fn test_format_duration_seconds_days() {
600        assert_eq!(format_duration_seconds(90061), "1d 1h 1m 1s");
601        assert_eq!(format_duration_seconds(345600), "4d");
602    }
603
604    #[test]
605    fn test_format_duration_seconds_complex() {
606        assert_eq!(format_duration_seconds(1800), "30m");
607        assert_eq!(format_duration_seconds(86400), "1d");
608    }
609
610    #[test]
611    fn test_render_pagination_single_page() {
612        assert_eq!(render_pagination(0, 1), "[1]");
613    }
614
615    #[test]
616    fn test_render_pagination_two_pages() {
617        assert_eq!(render_pagination(0, 2), "[1] 2");
618        assert_eq!(render_pagination(1, 2), "1 [2]");
619    }
620
621    #[test]
622    fn test_render_pagination_ten_pages() {
623        assert_eq!(render_pagination(0, 10), "[1] 2 3 4 5 6 7 8 9 10");
624        assert_eq!(render_pagination(5, 10), "1 2 3 4 5 [6] 7 8 9 10");
625        assert_eq!(render_pagination(9, 10), "1 2 3 4 5 6 7 8 9 [10]");
626    }
627
628    #[test]
629    fn test_format_memory_mb() {
630        assert_eq!(format_memory_mb(128), "128 MB");
631        assert_eq!(format_memory_mb(512), "512 MB");
632        assert_eq!(format_memory_mb(1024), "1 GB");
633        assert_eq!(format_memory_mb(2048), "2 GB");
634    }
635
636    #[test]
637    fn test_render_pagination_many_pages() {
638        assert_eq!(render_pagination(0, 20), "[1] 2 3 4 5 6 7 8 9");
639        assert_eq!(render_pagination(5, 20), "2 3 4 5 [6] 7 8 9 10");
640        assert_eq!(render_pagination(15, 20), "12 13 14 15 [16] 17 18 19 20");
641        assert_eq!(render_pagination(19, 20), "12 13 14 15 16 17 18 19 [20]");
642    }
643
644    #[test]
645    fn test_render_pagination_zero_total() {
646        assert_eq!(render_pagination(0, 0), "[1]");
647    }
648
649    #[test]
650    fn test_render_dropdown_items_format() {
651        let items = ["us-east-1", "us-west-2", "eu-west-1"];
652        assert_eq!(items.len(), 3);
653        assert_eq!(items[0], "us-east-1");
654        assert_eq!(items[2], "eu-west-1");
655    }
656
657    #[test]
658    fn test_render_dropdown_selected_index() {
659        let items = ["item1", "item2", "item3"];
660        let selected = 1;
661        assert_eq!(items[selected], "item2");
662    }
663
664    #[test]
665    fn test_render_dropdown_controls_after_width() {
666        let pagination_len = 10;
667        let separator = 3;
668        let controls_after = pagination_len + separator;
669        assert_eq!(controls_after, 13);
670    }
671
672    #[test]
673    fn test_render_dropdown_multiple_controls_after() {
674        let view_nested_width = 15;
675        let pagination_len = 10;
676        let controls_after = view_nested_width + 3 + pagination_len + 3;
677        assert_eq!(controls_after, 31);
678    }
679
680    #[test]
681    fn test_render_dropdown_clears_background() {
682        // This test verifies that render_dropdown uses Clear widget
683        // The actual rendering is tested via integration tests
684        // Here we just verify the function can be called with valid parameters
685        use ratatui::backend::TestBackend;
686        use ratatui::Terminal;
687
688        let backend = TestBackend::new(80, 24);
689        let mut terminal = Terminal::new(backend).unwrap();
690
691        terminal
692            .draw(|frame| {
693                let area = ratatui::prelude::Rect {
694                    x: 0,
695                    y: 0,
696                    width: 80,
697                    height: 3,
698                };
699                let items = ["Running", "Stopped", "Terminated"];
700                render_dropdown(frame, &items, 0, area, 10);
701            })
702            .unwrap();
703
704        // If we get here without panic, the function works correctly
705        // The Clear widget is used internally to clear the background
706    }
707}
708
709pub fn render_filter(frame: &mut Frame, config: FilterConfig) {
710    let cursor = if config.is_active { "█" } else { "" };
711    let content = if config.text.is_empty() && !config.is_active {
712        config.placeholder
713    } else {
714        config.text
715    };
716
717    let right_text = config
718        .right_content
719        .iter()
720        .map(|(k, v)| format!("{}: {}", k, v))
721        .collect::<Vec<_>>()
722        .join(" ⋮ ");
723
724    let width = (config.area.width as usize).saturating_sub(4);
725    let right_len = right_text.len();
726    let content_len = content.len() + if config.is_active { cursor.len() } else { 0 };
727    let available = width.saturating_sub(right_len + 3);
728
729    let display = if content_len > available {
730        &content[content_len.saturating_sub(available)..]
731    } else {
732        content
733    };
734
735    let style = if config.is_active {
736        styles::yellow()
737    } else {
738        styles::placeholder()
739    };
740
741    let mut spans = vec![Span::styled(display, style)];
742    if config.is_active {
743        spans.push(Span::styled(cursor, styles::cursor()));
744    }
745
746    let padding = " ".repeat(
747        width
748            .saturating_sub(display.len())
749            .saturating_sub(if config.is_active { cursor.len() } else { 0 })
750            .saturating_sub(right_len)
751            .saturating_sub(3),
752    );
753
754    spans.push(Span::raw(padding));
755    spans.push(Span::styled(format!(" {}", right_text), styles::cyan()));
756
757    frame.render_widget(
758        Paragraph::new(Line::from(spans)).block(
759            Block::default()
760                .borders(Borders::ALL)
761                .border_style(border_style(config.is_active)),
762        ),
763        config.area,
764    );
765}
766
767#[derive(Debug, Clone, Copy, PartialEq)]
768pub enum PageSize {
769    Ten,
770    TwentyFive,
771    Fifty,
772    OneHundred,
773}
774
775/// Generic helper to filter items by a field that matches a filter string (case-insensitive contains)
776pub fn filter_by_field<'a, T, F>(items: &'a [T], filter: &str, get_field: F) -> Vec<&'a T>
777where
778    F: Fn(&T) -> &str,
779{
780    if filter.is_empty() {
781        items.iter().collect()
782    } else {
783        let filter_lower = filter.to_lowercase();
784        items
785            .iter()
786            .filter(|item| get_field(item).to_lowercase().contains(&filter_lower))
787            .collect()
788    }
789}
790
791/// Generic helper to filter items by multiple fields (case-insensitive contains on any field)
792pub fn filter_by_fields<'a, T, F>(items: &'a [T], filter: &str, get_fields: F) -> Vec<&'a T>
793where
794    F: Fn(&T) -> Vec<&str>,
795{
796    if filter.is_empty() {
797        items.iter().collect()
798    } else {
799        let filter_lower = filter.to_lowercase();
800        items
801            .iter()
802            .filter(|item| {
803                get_fields(item)
804                    .iter()
805                    .any(|field| field.to_lowercase().contains(&filter_lower))
806            })
807            .collect()
808    }
809}
810
811#[cfg(test)]
812mod partial_pagination_tests {
813    use super::*;
814
815    #[test]
816    fn partial_pagination_never_invents_an_unloaded_page() {
817        assert_eq!(render_partial_pagination(0, 0), "...");
818        assert_eq!(render_partial_pagination(0, 1), "[1] ...");
819        assert_eq!(render_partial_pagination(0, 2), "[1] 2 ...");
820        assert_eq!(render_partial_pagination(1, 2), "1 [2] ...");
821        assert_eq!(
822            render_partial_pagination(19, 20),
823            "12 13 14 15 16 17 18 19 [20] ..."
824        );
825    }
826
827    #[test]
828    fn finite_pagination_is_unchanged() {
829        assert_eq!(render_pagination_text(0, 2), "[1] 2");
830        assert_eq!(render_pagination_text(3, 4), "1 2 3 [4]");
831        assert_eq!(render_pagination_text(0, 0), "[1]");
832    }
833}