Skip to main content

rusticity_term/ui/cw/
logs.rs

1// CloudWatch Logs UI rendering and state
2use crate::app::App;
3use crate::common::{
4    format_bytes, format_timestamp, render_pagination_text, render_vertical_scrollbar, CyclicEnum,
5    InputFocus, SortDirection,
6};
7use crate::cw::insights::{DateRangeType, TimeUnit};
8use crate::cw::logs::{EventColumn, LogGroupColumn, StreamColumn};
9use crate::keymap::Mode;
10use crate::table::TableWindow;
11use crate::ui::table::{expanded_from_columns, render_table, Column as TableColumn, TableConfig};
12use crate::ui::{
13    calculate_dynamic_height, filter_area, format_title, get_cursor, labeled_field,
14    render_fields_with_dynamic_columns, render_tabs,
15};
16use ratatui::{prelude::*, widgets::*};
17use rusticity_core::{LogEvent, LogGroup, LogStream};
18
19// State
20pub struct CloudWatchLogGroupsState {
21    pub log_groups: crate::table::TableState<LogGroup>,
22    pub log_streams: Vec<LogStream>,
23    pub log_events: Vec<LogEvent>,
24    pub tags: crate::table::TableState<(String, String)>,
25    pub selected_stream: usize,
26    pub selected_event: usize,
27    pub loading: bool,
28    pub loading_message: String,
29    pub detail_tab: DetailTab,
30    pub stream_filter: String,
31    pub exact_match: bool,
32    pub show_expired: bool,
33    pub filter_mode: bool,
34    pub input_focus: InputFocus,
35    pub stream_page: usize,
36    pub stream_sort: StreamSort,
37    pub stream_sort_desc: bool,
38    pub event_filter: String,
39    pub event_scroll_offset: usize,
40    pub event_horizontal_scroll: usize,
41    pub has_older_events: bool,
42    pub event_input_focus: EventFilterFocus,
43    pub stream_page_size: usize,
44    pub stream_current_page: usize,
45    pub expanded_event: Option<usize>,
46    pub expanded_stream: Option<usize>,
47    pub next_backward_token: Option<String>,
48    pub start_time: Option<i64>,
49    pub end_time: Option<i64>,
50    pub date_range_type: DateRangeType,
51    pub relative_amount: String,
52    pub relative_unit: TimeUnit,
53}
54
55impl CloudWatchLogGroupsState {
56    pub fn new() -> Self {
57        Self::default()
58    }
59}
60
61impl Default for CloudWatchLogGroupsState {
62    fn default() -> Self {
63        Self {
64            log_groups: crate::table::TableState::new(),
65            log_streams: Vec::new(),
66            log_events: Vec::new(),
67            tags: crate::table::TableState::new(),
68            selected_stream: 0,
69            selected_event: 0,
70            loading: false,
71            loading_message: String::new(),
72            detail_tab: DetailTab::LogStreams,
73            stream_filter: String::new(),
74            exact_match: false,
75            show_expired: false,
76            filter_mode: false,
77            input_focus: InputFocus::Filter,
78            stream_page: 0,
79            stream_sort: StreamSort::LastEventTime,
80            stream_sort_desc: true,
81            event_filter: String::new(),
82            event_scroll_offset: 0,
83            event_horizontal_scroll: 0,
84            has_older_events: true,
85            event_input_focus: EventFilterFocus::Filter,
86            stream_page_size: 20,
87            stream_current_page: 0,
88            expanded_event: None,
89            expanded_stream: None,
90            next_backward_token: None,
91            start_time: None,
92            end_time: None,
93            date_range_type: DateRangeType::Relative,
94            relative_amount: String::new(),
95            relative_unit: TimeUnit::Hours,
96        }
97    }
98}
99
100// Enums
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub enum StreamSort {
103    Name,
104    CreationTime,
105    LastEventTime,
106}
107
108pub const FILTER_CONTROLS: [InputFocus; 4] = [
109    InputFocus::Filter,
110    InputFocus::Checkbox("ExactMatch"),
111    InputFocus::Checkbox("ShowExpired"),
112    InputFocus::Pagination,
113];
114
115/// Simplified controls for the log groups list view (no checkboxes).
116pub const LIST_FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
117
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub enum EventFilterFocus {
120    Filter,
121    DateRange,
122}
123
124impl EventFilterFocus {
125    const ALL: [EventFilterFocus; 2] = [EventFilterFocus::Filter, EventFilterFocus::DateRange];
126
127    pub fn next(self) -> Self {
128        let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
129        Self::ALL[(idx + 1) % Self::ALL.len()]
130    }
131
132    pub fn prev(self) -> Self {
133        let idx = Self::ALL.iter().position(|&f| f == self).unwrap_or(0);
134        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq)]
139pub enum DetailTab {
140    LogStreams,
141    Tags,
142    AnomalyDetection,
143    MetricFilter,
144    SubscriptionFilters,
145    ContributorInsights,
146    DataProtection,
147    FieldIndexes,
148    Transformer,
149}
150
151impl CyclicEnum for DetailTab {
152    const ALL: &'static [Self] = &[Self::LogStreams, Self::Tags];
153}
154
155impl DetailTab {
156    pub fn name(&self) -> &'static str {
157        match self {
158            DetailTab::LogStreams => "Log streams",
159            DetailTab::Tags => "Tags",
160            DetailTab::AnomalyDetection => "Anomaly detection",
161            DetailTab::MetricFilter => "Metric filter",
162            DetailTab::SubscriptionFilters => "Subscription filters",
163            DetailTab::ContributorInsights => "Contributor insights",
164            DetailTab::DataProtection => "Data protection",
165            DetailTab::FieldIndexes => "Field indexes",
166            DetailTab::Transformer => "Transformer",
167        }
168    }
169
170    pub fn all() -> Vec<DetailTab> {
171        vec![DetailTab::LogStreams, DetailTab::Tags]
172    }
173}
174
175// Helper functions
176
177pub fn selected_log_group(app: &App) -> Option<&LogGroup> {
178    app.log_groups_state
179        .log_groups
180        .items
181        .get(app.log_groups_state.log_groups.selected)
182}
183
184pub fn filtered_log_groups(app: &App) -> Vec<&LogGroup> {
185    if app.log_groups_state.log_groups.filter.is_empty() {
186        app.log_groups_state.log_groups.items.iter().collect()
187    } else {
188        app.log_groups_state
189            .log_groups
190            .items
191            .iter()
192            .filter(|group| {
193                if app.log_groups_state.exact_match {
194                    group.name == app.log_groups_state.log_groups.filter
195                } else {
196                    group.name.contains(&app.log_groups_state.log_groups.filter)
197                }
198            })
199            .collect()
200    }
201}
202
203pub fn filtered_log_streams(app: &App) -> Vec<&LogStream> {
204    let mut streams: Vec<&LogStream> = if app.log_groups_state.stream_filter.is_empty() {
205        app.log_groups_state.log_streams.iter().collect()
206    } else {
207        app.log_groups_state
208            .log_streams
209            .iter()
210            .filter(|stream| {
211                if app.log_groups_state.exact_match {
212                    stream.name == app.log_groups_state.stream_filter
213                } else {
214                    stream.name.contains(&app.log_groups_state.stream_filter)
215                }
216            })
217            .collect()
218    };
219
220    // Filter out expired streams unless show_expired is enabled
221    if !app.log_groups_state.show_expired {
222        if let Some(group) = selected_log_group(app) {
223            if let Some(retention_days) = group.retention_days {
224                let now = chrono::Utc::now();
225                let retention_cutoff = now - chrono::Duration::days(retention_days as i64);
226
227                streams.retain(|stream| {
228                    stream
229                        .last_event_time
230                        .map(|t| t > retention_cutoff)
231                        .unwrap_or(false)
232                });
233            }
234        }
235    }
236
237    streams.sort_by(|a, b| {
238        let cmp = match app.log_groups_state.stream_sort {
239            StreamSort::Name => a.name.cmp(&b.name),
240            StreamSort::CreationTime => a.creation_time.cmp(&b.creation_time),
241            StreamSort::LastEventTime => a.last_event_time.cmp(&b.last_event_time),
242        };
243        if app.log_groups_state.stream_sort_desc {
244            cmp.reverse()
245        } else {
246            cmp
247        }
248    });
249
250    streams
251}
252
253pub fn filtered_log_events(app: &App) -> Vec<&LogEvent> {
254    if app.log_groups_state.event_filter.is_empty() {
255        app.log_groups_state.log_events.iter().collect()
256    } else {
257        app.log_groups_state
258            .log_events
259            .iter()
260            .filter(|event| event.message.contains(&app.log_groups_state.event_filter))
261            .collect()
262    }
263}
264
265pub fn render_groups_list(frame: &mut Frame, app: &App, area: Rect) {
266    let chunks = Layout::default()
267        .direction(Direction::Vertical)
268        .constraints([
269            Constraint::Length(3), // Filter
270            Constraint::Min(0),    // Table
271        ])
272        .split(area);
273
274    let placeholder = "Filter loaded log groups or try prefix search";
275    let view = app
276        .log_groups_state
277        .log_groups
278        .prepare_view(filtered_log_groups(app), TableWindow::ScrollOffset);
279    let pagination = render_pagination_text(view.current_page, view.total_pages);
280
281    crate::ui::filter::render_simple_filter(
282        frame,
283        chunks[0],
284        crate::ui::filter::SimpleFilterConfig {
285            filter_text: &app.log_groups_state.log_groups.filter,
286            placeholder,
287            pagination: &pagination,
288            mode: app.mode,
289            is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
290            is_pagination_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
291        },
292    );
293
294    let mut columns: Vec<Box<dyn TableColumn<LogGroup>>> = vec![];
295
296    for col_id in &app.cw_log_group_visible_column_ids {
297        let Some(col) = LogGroupColumn::from_id(col_id) else {
298            continue;
299        };
300        columns.push(Box::new(col));
301    }
302
303    let config = TableConfig {
304        items: view.items,
305        selected_index: view.selected_index,
306        expanded_index: view.expanded_index,
307        columns: &columns,
308        sort_column: "",
309        sort_direction: SortDirection::Asc,
310        title: format_title(&format!("Log groups ({})", view.total_items)),
311        area: chunks[1],
312        get_expanded_content: Some(Box::new(|group: &LogGroup| {
313            expanded_from_columns(&columns, group)
314        })),
315        is_active: app.mode != Mode::FilterInput,
316    };
317
318    render_table(frame, config);
319}
320
321pub fn render_group_detail(frame: &mut Frame, app: &App, area: Rect) {
322    frame.render_widget(Clear, area);
323
324    let is_active = !matches!(
325        app.mode,
326        Mode::SpaceMenu
327            | Mode::ServicePicker
328            | Mode::ColumnSelector
329            | Mode::ErrorModal
330            | Mode::HelpModal
331            | Mode::RegionPicker
332            | Mode::CalendarPicker
333            | Mode::TabPicker
334    );
335    let border_style = if is_active {
336        Style::default().fg(Color::Green)
337    } else {
338        Style::default()
339    };
340
341    let detail_height = if let Some(group) = selected_log_group(app) {
342        let arn = format!(
343            "arn:aws:logs:{}:{}:log-group:{}:*",
344            app.config.region, app.config.account_id, group.name
345        );
346        let creation_time = group
347            .creation_time
348            .map(|t| format_timestamp(&t))
349            .unwrap_or_else(|| "-".to_string());
350        let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
351        let deletion_protection = if group.deletion_protection_enabled.unwrap_or(false) {
352            "On"
353        } else {
354            "Off"
355        };
356
357        let lines = vec![
358            labeled_field("Log class", "Standard"),
359            labeled_field("Retention", "Never expire"),
360            labeled_field("Stored bytes", stored_bytes),
361            labeled_field("Deletion protection", deletion_protection),
362            labeled_field("Creation time", creation_time),
363            labeled_field("ARN", arn),
364            Line::from(vec![
365                Span::styled(
366                    "Metric filters: ",
367                    Style::default().add_modifier(Modifier::BOLD),
368                ),
369                Span::raw("0"),
370            ]),
371            Line::from(vec![
372                Span::styled(
373                    "Subscription filters: ",
374                    Style::default().add_modifier(Modifier::BOLD),
375                ),
376                Span::raw("0"),
377            ]),
378            Line::from(vec![
379                Span::styled(
380                    "KMS key ID: ",
381                    Style::default().add_modifier(Modifier::BOLD),
382                ),
383                Span::raw("-"),
384            ]),
385        ];
386
387        calculate_dynamic_height(&lines, area.width.saturating_sub(2)) + 2
388    } else {
389        3
390    };
391
392    let chunks = Layout::default()
393        .direction(Direction::Vertical)
394        .constraints([
395            Constraint::Length(detail_height),
396            Constraint::Length(1),
397            Constraint::Min(0),
398        ])
399        .split(area);
400
401    if let Some(group) = selected_log_group(app) {
402        let detail_block = Block::default()
403            .title(format_title("Log group details"))
404            .borders(Borders::ALL)
405            .border_type(BorderType::Rounded)
406            .border_style(Style::default());
407        let inner = detail_block.inner(chunks[0]);
408        frame.render_widget(detail_block, chunks[0]);
409        frame.render_widget(Clear, inner);
410
411        let arn = format!(
412            "arn:aws:logs:{}:{}:log-group:{}:*",
413            app.config.region, app.config.account_id, group.name
414        );
415        let creation_time = group
416            .creation_time
417            .map(|t| format_timestamp(&t))
418            .unwrap_or_else(|| "-".to_string());
419        let stored_bytes = format_bytes(group.stored_bytes.unwrap_or(0));
420        let deletion_protection = if group.deletion_protection_enabled.unwrap_or(false) {
421            "On"
422        } else {
423            "Off"
424        };
425
426        let lines = vec![
427            labeled_field("Log class", "Standard"),
428            labeled_field("Retention", "Never expire"),
429            labeled_field("Stored bytes", stored_bytes),
430            labeled_field("Deletion protection", deletion_protection),
431            labeled_field("Creation time", creation_time),
432            labeled_field("ARN", arn),
433            Line::from(vec![
434                Span::styled(
435                    "Metric filters: ",
436                    Style::default().add_modifier(Modifier::BOLD),
437                ),
438                Span::raw("0"),
439            ]),
440            Line::from(vec![
441                Span::styled(
442                    "Subscription filters: ",
443                    Style::default().add_modifier(Modifier::BOLD),
444                ),
445                Span::raw("0"),
446            ]),
447            Line::from(vec![
448                Span::styled(
449                    "KMS key ID: ",
450                    Style::default().add_modifier(Modifier::BOLD),
451                ),
452                Span::raw("-"),
453            ]),
454        ];
455
456        render_fields_with_dynamic_columns(frame, inner, lines);
457    }
458
459    render_tab_menu(frame, app, chunks[1]);
460
461    match app.log_groups_state.detail_tab {
462        DetailTab::LogStreams => render_log_streams_table(frame, app, chunks[2], border_style),
463        DetailTab::Tags => render_tags_table(frame, app, chunks[2], border_style),
464        _ => render_tab_placeholder(frame, app, chunks[2], border_style),
465    }
466}
467
468fn render_tab_menu(frame: &mut Frame, app: &App, area: Rect) {
469    frame.render_widget(Clear, area);
470    let all_tabs = DetailTab::all();
471    let tabs: Vec<(&str, DetailTab)> = all_tabs.iter().map(|tab| (tab.name(), *tab)).collect();
472
473    // Debug: verify we have both tabs
474    debug_assert_eq!(tabs.len(), 2, "Should have 2 tabs: LogStreams and Tags");
475
476    render_tabs(frame, area, &tabs, &app.log_groups_state.detail_tab);
477}
478
479fn render_tab_placeholder(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
480    frame.render_widget(Clear, area);
481    let text = format!("{} - Coming soon", app.log_groups_state.detail_tab.name());
482    let paragraph = Paragraph::new(text)
483        .block(
484            Block::default()
485                .borders(Borders::ALL)
486                .border_type(BorderType::Rounded)
487                .border_style(border_style),
488        )
489        .style(Style::default().fg(Color::Gray));
490    frame.render_widget(paragraph, area);
491}
492fn render_log_streams_table(frame: &mut Frame, app: &App, area: Rect, border_style: Style) {
493    frame.render_widget(Clear, area);
494
495    let chunks = Layout::default()
496        .direction(Direction::Vertical)
497        .constraints([Constraint::Length(3), Constraint::Min(0)])
498        .split(area);
499
500    let placeholder = "Filter loaded log streams or try prefix search";
501
502    let exact_match_text = if app.log_groups_state.exact_match {
503        "☑ Exact match"
504    } else {
505        "☐ Exact match"
506    };
507    let show_expired_text = if app.log_groups_state.show_expired {
508        "☑ Show expired"
509    } else {
510        "☐ Show expired"
511    };
512
513    let filtered_streams = filtered_log_streams(app);
514    let count = filtered_streams.len();
515    let page_size = app.log_groups_state.stream_page_size;
516    let total_pages = count.div_ceil(page_size);
517    let current_page = app
518        .log_groups_state
519        .stream_current_page
520        .min(total_pages.saturating_sub(1));
521
522    // Paginate the filtered streams
523    let start_idx = current_page * page_size;
524    let end_idx = (start_idx + page_size).min(count);
525    let paginated_streams = filtered_streams[start_idx..end_idx].to_vec();
526
527    let pagination = render_pagination_text(current_page, total_pages);
528
529    crate::ui::filter::render_filter_bar(
530        frame,
531        crate::ui::filter::FilterConfig {
532            filter_text: &app.log_groups_state.stream_filter,
533            placeholder,
534            mode: app.mode,
535            is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
536            controls: vec![
537                crate::ui::filter::FilterControl {
538                    text: exact_match_text.to_string(),
539                    is_focused: app.log_groups_state.input_focus
540                        == InputFocus::Checkbox("ExactMatch"),
541                },
542                crate::ui::filter::FilterControl {
543                    text: show_expired_text.to_string(),
544                    is_focused: app.log_groups_state.input_focus
545                        == InputFocus::Checkbox("ShowExpired"),
546                },
547                crate::ui::filter::FilterControl {
548                    text: pagination,
549                    is_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
550                },
551            ],
552            area: chunks[0],
553        },
554    );
555
556    let columns: Vec<Box<dyn TableColumn<LogStream>>> = app
557        .cw_log_stream_visible_column_ids
558        .iter()
559        .filter_map(|col_id| {
560            StreamColumn::from_id(col_id)
561                .map(|col| Box::new(col) as Box<dyn TableColumn<LogStream>>)
562        })
563        .collect();
564
565    let count_display = if count >= 100 {
566        "100+".to_string()
567    } else {
568        count.to_string()
569    };
570
571    let sort_column = match app.log_groups_state.stream_sort {
572        StreamSort::Name => "Log stream",
573        StreamSort::CreationTime => "Creation time",
574        StreamSort::LastEventTime => "Last event time",
575    };
576
577    let sort_direction = if app.log_groups_state.stream_sort_desc {
578        SortDirection::Desc
579    } else {
580        SortDirection::Asc
581    };
582
583    let config = TableConfig {
584        items: paginated_streams,
585        selected_index: if count > 0 {
586            app.log_groups_state
587                .selected_stream
588                .saturating_sub(start_idx)
589                .min(page_size.saturating_sub(1))
590        } else {
591            0
592        },
593        expanded_index: app
594            .log_groups_state
595            .expanded_stream
596            .map(|idx| idx.saturating_sub(start_idx)),
597        columns: &columns,
598        sort_column,
599        sort_direction,
600        title: format_title(&format!("Log streams ({})", count_display)),
601        area: chunks[1],
602        get_expanded_content: Some(Box::new(|stream: &LogStream| {
603            expanded_from_columns(&columns, stream)
604        })),
605        is_active: border_style.fg == Some(Color::Green)
606            && (app.mode != Mode::FilterInput
607                || app.log_groups_state.input_focus != InputFocus::Filter),
608    };
609
610    render_table(frame, config);
611}
612
613pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
614    frame.render_widget(Clear, area);
615
616    let is_active = !matches!(
617        app.mode,
618        Mode::SpaceMenu
619            | Mode::ServicePicker
620            | Mode::ColumnSelector
621            | Mode::ErrorModal
622            | Mode::HelpModal
623            | Mode::RegionPicker
624            | Mode::CalendarPicker
625            | Mode::TabPicker
626    );
627    let border_style = if is_active {
628        Style::default().fg(Color::Green)
629    } else {
630        Style::default()
631    };
632
633    let chunks = Layout::default()
634        .direction(Direction::Vertical)
635        .constraints([Constraint::Length(3), Constraint::Min(0)])
636        .split(area);
637
638    // Filter and date range
639    let filter_chunks = Layout::default()
640        .direction(Direction::Horizontal)
641        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
642        .split(chunks[0]);
643
644    let cursor = get_cursor(
645        app.mode == Mode::EventFilterInput
646            && app.log_groups_state.event_input_focus == EventFilterFocus::Filter,
647    );
648    let filter_text =
649        if app.log_groups_state.event_filter.is_empty() && app.mode != Mode::EventFilterInput {
650            vec![
651                Span::styled(
652                    "Filter events - press ⏎ to search",
653                    Style::default().fg(Color::DarkGray),
654                ),
655                Span::styled(cursor, Style::default().fg(Color::Yellow)),
656            ]
657        } else {
658            vec![
659                Span::raw(&app.log_groups_state.event_filter),
660                Span::styled(cursor, Style::default().fg(Color::Yellow)),
661            ]
662        };
663
664    let is_filter_active = app.mode == Mode::EventFilterInput
665        && app.log_groups_state.event_input_focus == EventFilterFocus::Filter;
666    let filter = filter_area(filter_text, is_filter_active);
667
668    let date_border_style = if app.mode == Mode::EventFilterInput
669        && app.log_groups_state.event_input_focus == EventFilterFocus::DateRange
670    {
671        Style::default().fg(Color::Green)
672    } else {
673        Style::default()
674    };
675
676    let date_range_text = vec![
677        Span::raw(format!(
678            "Last [{}] <{}>",
679            if app.log_groups_state.relative_amount.is_empty() {
680                "_"
681            } else {
682                &app.log_groups_state.relative_amount
683            },
684            app.log_groups_state.relative_unit.name()
685        )),
686        Span::styled(cursor, Style::default().fg(Color::Yellow)),
687    ];
688
689    let date_range = Paragraph::new(Line::from(date_range_text)).block(
690        Block::default()
691            .title(format_title("Date range"))
692            .borders(Borders::ALL)
693            .border_type(BorderType::Rounded)
694            .border_style(date_border_style),
695    );
696
697    frame.render_widget(filter, filter_chunks[0]);
698    frame.render_widget(date_range, filter_chunks[1]);
699
700    // Events table with banner
701    let table_area = chunks[1];
702
703    let header_cells = app
704        .cw_log_event_visible_column_ids
705        .iter()
706        .enumerate()
707        .filter_map(|(i, col_id)| {
708            EventColumn::from_id(col_id).map(|col| {
709                let name = if i > 0 {
710                    format!("⋮ {}", col.name())
711                } else {
712                    col.name().to_string()
713                };
714                Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
715            })
716        })
717        .collect::<Vec<_>>();
718    let header = Row::new(header_cells)
719        .style(Style::default().bg(Color::White).fg(Color::Black))
720        .height(1);
721
722    let visible_events: Vec<_> = filtered_log_events(app).into_iter().collect();
723
724    // Add banner as first row if there are older events
725    let mut all_rows: Vec<Row> = Vec::new();
726
727    if app.log_groups_state.has_older_events {
728        let banner_cells = vec![
729            Cell::from(""),
730            Cell::from("There are older events to load. Scroll up to load more.")
731                .style(Style::default().fg(Color::Yellow)),
732        ];
733        all_rows.push(Row::new(banner_cells).height(1));
734    }
735
736    // Calculate available width for message column
737    let table_width = table_area.width.saturating_sub(4) as usize; // borders + spacing
738    let fixed_width: usize = app
739        .cw_log_event_visible_column_ids
740        .iter()
741        .filter_map(|col_id| EventColumn::from_id(col_id))
742        .filter(|col| col.width() > 0)
743        .map(|col| col.width() as usize + 1) // +1 for spacing
744        .sum();
745    let message_max_width = table_width.saturating_sub(fixed_width).saturating_sub(3); // -3 for highlight symbol
746
747    let mut table_row_to_event_idx = Vec::new();
748    let event_rows = visible_events.iter().enumerate().flat_map(|(idx, event)| {
749        let is_expanded = app.log_groups_state.expanded_event == Some(idx);
750        let is_selected = idx == app.log_groups_state.event_scroll_offset;
751
752        let mut rows = Vec::new();
753
754        // Main row with columns - always show first line or truncated message
755        let mut cells: Vec<Cell> = Vec::new();
756        for (i, col_id) in app.cw_log_event_visible_column_ids.iter().enumerate() {
757            let Some(col) = EventColumn::from_id(col_id) else {
758                continue;
759            };
760            let content = match col {
761                EventColumn::Timestamp => {
762                    let timestamp_str = format_timestamp(&event.timestamp);
763                    crate::ui::table::format_expandable_with_selection(
764                        &timestamp_str,
765                        is_expanded,
766                        is_selected,
767                    )
768                }
769                EventColumn::Message => {
770                    let msg = event
771                        .message
772                        .lines()
773                        .next()
774                        .unwrap_or("")
775                        .replace('\t', " ");
776                    if msg.len() > message_max_width {
777                        format!("{}…", &msg[..message_max_width.saturating_sub(1)])
778                    } else {
779                        msg
780                    }
781                }
782                EventColumn::IngestionTime => event
783                    .ingestion_time
784                    .map(|t| format_timestamp(&t))
785                    .unwrap_or_else(|| "-".to_string()),
786                EventColumn::EventId => "-".to_string(),
787                EventColumn::LogStreamName => app
788                    .log_groups_state
789                    .log_streams
790                    .get(app.log_groups_state.selected_stream)
791                    .map(|s| s.name.clone())
792                    .unwrap_or_else(|| "-".to_string()),
793            };
794
795            let cell_content = if i > 0 {
796                format!("⋮ {}", content)
797            } else {
798                content
799            };
800
801            cells.push(Cell::from(cell_content));
802        }
803        table_row_to_event_idx.push(idx);
804        rows.push(Row::new(cells).height(1));
805
806        // If expanded, add empty rows to reserve space for overlay
807        if is_expanded {
808            // Calculate wrapped line count
809            let max_width = (table_area.width.saturating_sub(3)) as usize;
810            let mut line_count = 0;
811
812            for col_id in &app.cw_log_event_visible_column_ids {
813                let Some(col) = EventColumn::from_id(col_id) else {
814                    continue;
815                };
816                let value = match col {
817                    EventColumn::Timestamp => format_timestamp(&event.timestamp),
818                    EventColumn::Message => event.message.replace('\t', "    "),
819                    _ => "-".to_string(),
820                };
821                let full_line = format!("{}: {}", col.name(), value);
822                line_count += full_line.len().div_ceil(max_width);
823            }
824
825            for _ in 0..line_count {
826                // Empty row to reserve space - will be covered by overlay
827                table_row_to_event_idx.push(idx);
828                rows.push(Row::new(vec![Cell::from("")]).height(1));
829            }
830        }
831
832        rows
833    });
834
835    all_rows.extend(event_rows);
836
837    let banner_offset = if app.log_groups_state.has_older_events {
838        1
839    } else {
840        0
841    };
842    let mut table_state_index = banner_offset;
843    for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
844        if event_idx == app.log_groups_state.event_scroll_offset {
845            table_state_index = banner_offset + i;
846            break;
847        }
848    }
849
850    let widths: Vec<Constraint> = app
851        .cw_log_event_visible_column_ids
852        .iter()
853        .filter_map(|col_id| {
854            EventColumn::from_id(col_id).map(|col| {
855                if col.width() == 0 {
856                    Constraint::Min(0)
857                } else {
858                    Constraint::Length(col.width())
859                }
860            })
861        })
862        .collect();
863
864    let table = Table::new(all_rows, widths)
865        .header(header)
866        .block(
867            Block::default()
868                .title(format_title(&format!(
869                    "Log events ({})",
870                    visible_events.len()
871                )))
872                .borders(Borders::ALL)
873                .border_type(BorderType::Rounded)
874                .border_style(border_style),
875        )
876        .column_spacing(1)
877        .row_highlight_style(Style::default().bg(Color::DarkGray))
878        .highlight_symbol("");
879
880    let mut state = TableState::default();
881    state.select(Some(table_state_index));
882
883    frame.render_stateful_widget(table, table_area, &mut state);
884
885    // Render expanded content as overlay
886    if let Some(expanded_idx) = app.log_groups_state.expanded_event {
887        if let Some(event) = visible_events.get(expanded_idx) {
888            // Find row position
889            let mut row_y = 0;
890            for (i, &event_idx) in table_row_to_event_idx.iter().enumerate() {
891                if event_idx == expanded_idx {
892                    row_y = i;
893                    break;
894                }
895            }
896
897            let banner_offset = if app.log_groups_state.has_older_events {
898                1
899            } else {
900                0
901            };
902
903            // Build content with column names
904            let mut lines = Vec::new();
905            let max_width = table_area.width.saturating_sub(3) as usize;
906
907            for col_id in &app.cw_log_event_visible_column_ids {
908                let Some(col) = EventColumn::from_id(col_id) else {
909                    continue;
910                };
911                let value = match col {
912                    EventColumn::Timestamp => format_timestamp(&event.timestamp),
913                    EventColumn::Message => event.message.replace('\t', "    "),
914                    EventColumn::IngestionTime => event
915                        .ingestion_time
916                        .map(|t| format_timestamp(&t))
917                        .unwrap_or_else(|| "-".to_string()),
918                    EventColumn::EventId => "-".to_string(),
919                    EventColumn::LogStreamName => app
920                        .log_groups_state
921                        .log_streams
922                        .get(app.log_groups_state.selected_stream)
923                        .map(|s| s.name.clone())
924                        .unwrap_or_else(|| "-".to_string()),
925                };
926                let col_name = format!("{}: ", col.name());
927                let full_line = format!("{}{}", col_name, value);
928
929                // Wrap long lines, marking first line
930                if full_line.len() <= max_width {
931                    lines.push((full_line, true)); // true = first line with column name
932                } else {
933                    // First chunk includes column name
934                    let first_chunk_len = max_width.min(full_line.len());
935                    lines.push((full_line[..first_chunk_len].to_string(), true));
936
937                    // Remaining chunks are continuation
938                    let mut remaining = &full_line[first_chunk_len..];
939                    while !remaining.is_empty() {
940                        let take = max_width.min(remaining.len());
941                        lines.push((remaining[..take].to_string(), false)); // false = continuation
942                        remaining = &remaining[take..];
943                    }
944                }
945            }
946
947            // Render each line as overlay
948            // Clear entire expanded area once
949            let start_y = table_area.y + 2 + banner_offset as u16 + row_y as u16 + 1;
950            let max_y = table_area.y + table_area.height - 1;
951
952            // Only render if start_y is within bounds
953            if start_y < max_y {
954                let available_height = (max_y - start_y) as usize;
955                let visible_lines = lines.len().min(available_height);
956
957                if visible_lines > 0 {
958                    let clear_area = Rect {
959                        x: table_area.x + 1,
960                        y: start_y,
961                        width: table_area.width.saturating_sub(3),
962                        height: visible_lines as u16,
963                    };
964                    frame.render_widget(Clear, clear_area);
965                }
966
967                for (line_idx, (line, is_first)) in lines.iter().enumerate() {
968                    let y = start_y + line_idx as u16;
969                    if y >= max_y {
970                        break;
971                    }
972
973                    let line_area = Rect {
974                        x: table_area.x + 1,
975                        y,
976                        width: table_area.width.saturating_sub(3), // Leave room for scrollbar
977                        height: 1,
978                    };
979
980                    // Add expansion indicator on the left
981                    let is_last_line = line_idx == lines.len() - 1;
982                    let indicator = if is_last_line {
983                        "╰ "
984                    } else if *is_first {
985                        "├ "
986                    } else {
987                        "│ "
988                    };
989
990                    // Bold column name only on first line of each field
991                    let spans = if *is_first {
992                        if let Some(colon_pos) = line.find(": ") {
993                            let col_name = &line[..colon_pos + 2];
994                            let rest = &line[colon_pos + 2..];
995                            vec![
996                                Span::raw(indicator),
997                                Span::styled(
998                                    col_name.to_string(),
999                                    Style::default().add_modifier(Modifier::BOLD),
1000                                ),
1001                                Span::raw(rest.to_string()),
1002                            ]
1003                        } else {
1004                            vec![Span::raw(indicator), Span::raw(line.clone())]
1005                        }
1006                    } else {
1007                        // Continuation line - no bold
1008                        vec![Span::raw(indicator), Span::raw(line.clone())]
1009                    };
1010
1011                    let paragraph = Paragraph::new(Line::from(spans));
1012                    frame.render_widget(paragraph, line_area);
1013                }
1014            }
1015        }
1016    }
1017
1018    // Render scrollbar
1019    let event_count = app.log_groups_state.log_events.len();
1020    if event_count > 0 {
1021        render_vertical_scrollbar(
1022            frame,
1023            table_area.inner(Margin {
1024                vertical: 1,
1025                horizontal: 0,
1026            }),
1027            event_count,
1028            app.log_groups_state.event_scroll_offset,
1029        );
1030    }
1031}
1032
1033fn render_tags_table(frame: &mut Frame, app: &App, area: Rect, _border_style: Style) {
1034    use crate::cw::TagColumn;
1035    use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
1036    use crate::ui::table::{render_table, Column, TableConfig};
1037    use crate::ui::vertical;
1038
1039    let chunks = vertical(
1040        [
1041            Constraint::Length(3), // Filter with pagination
1042            Constraint::Min(0),    // Table
1043        ],
1044        area,
1045    );
1046
1047    // Filter tags
1048    let page_size = app.log_groups_state.tags.page_size.value().max(1);
1049    let filtered_tags: Vec<_> = app
1050        .log_groups_state
1051        .tags
1052        .items
1053        .iter()
1054        .filter(|t| {
1055            if app.log_groups_state.tags.filter.is_empty() {
1056                true
1057            } else {
1058                t.0.to_lowercase()
1059                    .contains(&app.log_groups_state.tags.filter.to_lowercase())
1060                    || t.1
1061                        .to_lowercase()
1062                        .contains(&app.log_groups_state.tags.filter.to_lowercase())
1063            }
1064        })
1065        .collect();
1066
1067    let filtered_count = filtered_tags.len();
1068    let total_pages = filtered_count.div_ceil(page_size);
1069    let current_page = app.log_groups_state.tags.selected / page_size;
1070    let pagination = render_pagination_text(current_page, total_pages);
1071
1072    render_simple_filter(
1073        frame,
1074        chunks[0],
1075        SimpleFilterConfig {
1076            filter_text: &app.log_groups_state.tags.filter,
1077            placeholder: "Search",
1078            pagination: &pagination,
1079            mode: app.mode,
1080            is_input_focused: app.log_groups_state.input_focus == InputFocus::Filter,
1081            is_pagination_focused: app.log_groups_state.input_focus == InputFocus::Pagination,
1082        },
1083    );
1084
1085    // Paginate
1086    let scroll_offset = app.log_groups_state.tags.scroll_offset;
1087    let page_tags: Vec<&(String, String)> = filtered_tags
1088        .into_iter()
1089        .skip(scroll_offset)
1090        .take(page_size)
1091        .collect();
1092
1093    let columns: Vec<Box<dyn Column<(String, String)>>> = app
1094        .cw_log_tag_visible_column_ids
1095        .iter()
1096        .filter_map(|col_id| {
1097            TagColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<(String, String)>>)
1098        })
1099        .collect();
1100
1101    let config = TableConfig {
1102        items: page_tags,
1103        selected_index: app
1104            .log_groups_state
1105            .tags
1106            .selected
1107            .saturating_sub(scroll_offset),
1108        expanded_index: None,
1109        columns: &columns,
1110        sort_column: "Key",
1111        sort_direction: crate::common::SortDirection::Asc,
1112        title: format_title(&format!("Tags ({})", filtered_count)),
1113        area: chunks[1],
1114        get_expanded_content: None,
1115        is_active: app.mode == Mode::Normal,
1116    };
1117
1118    render_table(frame, config);
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124    use crate::app::App;
1125
1126    fn test_app() -> App {
1127        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1128    }
1129
1130    #[test]
1131    fn test_prepared_groups_uses_scroll_offset_and_handles_stale_window() {
1132        use ratatui::{backend::TestBackend, Terminal};
1133        let mut app = test_app();
1134        app.mode = Mode::Normal;
1135        app.log_groups_state.log_groups.items = (0..25)
1136            .map(|i| LogGroup {
1137                name: format!("group{i:02}"),
1138                creation_time: None,
1139                stored_bytes: None,
1140                retention_days: None,
1141                log_class: None,
1142                arn: None,
1143                log_group_arn: None,
1144                deletion_protection_enabled: None,
1145            })
1146            .collect();
1147        app.log_groups_state.log_groups.page_size = crate::common::PageSize::Ten;
1148        app.log_groups_state.log_groups.scroll_offset = 7;
1149        app.log_groups_state.log_groups.selected = 14;
1150        app.log_groups_state.log_groups.expanded_item = Some(9);
1151        let mut terminal = Terminal::new(TestBackend::new(200, 45)).unwrap();
1152        terminal
1153            .draw(|f| render_groups_list(f, &app, f.area()))
1154            .unwrap();
1155        let content = terminal
1156            .backend()
1157            .buffer()
1158            .content()
1159            .iter()
1160            .map(|c| c.symbol())
1161            .collect::<String>();
1162        assert!(content.contains("Log groups (25)"));
1163        assert!(content.contains(&render_pagination_text(0, 3)));
1164        assert!(content.contains("group07"));
1165        assert!(content.contains("group16"));
1166        assert!(content.contains("► group14"));
1167        assert!(content.contains("▼ group09"));
1168        assert!(!content.contains("group06"));
1169        assert!(!content.contains("group17"));
1170
1171        app.log_groups_state.log_groups.filter = "no-match".into();
1172        terminal
1173            .draw(|f| render_groups_list(f, &app, f.area()))
1174            .unwrap();
1175        let content = terminal
1176            .backend()
1177            .buffer()
1178            .content()
1179            .iter()
1180            .map(|c| c.symbol())
1181            .collect::<String>();
1182        assert!(content.contains("Log groups (0)"));
1183        assert!(!content.contains("group14"));
1184        assert_eq!(app.log_groups_state.log_groups.scroll_offset, 7);
1185        assert_eq!(app.log_groups_state.log_groups.selected, 14);
1186    }
1187
1188    #[test]
1189    fn test_input_focus_enum_cycling() {
1190        use InputFocus;
1191        assert_eq!(
1192            InputFocus::Filter.next(&FILTER_CONTROLS),
1193            InputFocus::Checkbox("ExactMatch")
1194        );
1195        assert_eq!(
1196            InputFocus::Checkbox("ExactMatch").next(&FILTER_CONTROLS),
1197            InputFocus::Checkbox("ShowExpired")
1198        );
1199        assert_eq!(
1200            InputFocus::Checkbox("ShowExpired").next(&FILTER_CONTROLS),
1201            InputFocus::Pagination
1202        );
1203        assert_eq!(
1204            InputFocus::Pagination.next(&FILTER_CONTROLS),
1205            InputFocus::Filter
1206        );
1207
1208        assert_eq!(
1209            InputFocus::Filter.prev(&FILTER_CONTROLS),
1210            InputFocus::Pagination
1211        );
1212        assert_eq!(
1213            InputFocus::Pagination.prev(&FILTER_CONTROLS),
1214            InputFocus::Checkbox("ShowExpired")
1215        );
1216        assert_eq!(
1217            InputFocus::Checkbox("ShowExpired").prev(&FILTER_CONTROLS),
1218            InputFocus::Checkbox("ExactMatch")
1219        );
1220        assert_eq!(
1221            InputFocus::Checkbox("ExactMatch").prev(&FILTER_CONTROLS),
1222            InputFocus::Filter
1223        );
1224    }
1225
1226    #[test]
1227    fn test_exact_match_toggle_with_space() {
1228        use crate::app::{Service, ViewMode};
1229        use crate::keymap::{Action, Mode};
1230
1231        let mut app = test_app();
1232        app.current_service = Service::CloudWatchLogGroups;
1233        app.view_mode = ViewMode::Detail;
1234        app.mode = Mode::FilterInput;
1235        app.log_groups_state.detail_tab = DetailTab::LogStreams;
1236        app.log_groups_state.input_focus = InputFocus::Checkbox("ExactMatch");
1237
1238        // Initially false
1239        assert!(!app.log_groups_state.exact_match);
1240
1241        // Toggle with space
1242        app.handle_action(Action::ToggleFilterCheckbox);
1243        assert!(app.log_groups_state.exact_match);
1244
1245        // Toggle again
1246        app.handle_action(Action::ToggleFilterCheckbox);
1247        assert!(!app.log_groups_state.exact_match);
1248    }
1249
1250    #[test]
1251    fn test_exact_match_filters_log_groups() {
1252        use crate::app::Service;
1253        use rusticity_core::LogGroup;
1254
1255        let mut app = test_app();
1256        app.current_service = Service::CloudWatchLogGroups;
1257
1258        // Add test log groups
1259        app.log_groups_state.log_groups.items = vec![
1260            LogGroup {
1261                name: "/aws/lambda/test".to_string(),
1262                creation_time: None,
1263                stored_bytes: None,
1264                retention_days: None,
1265                log_class: None,
1266                arn: None,
1267                log_group_arn: None,
1268                deletion_protection_enabled: None,
1269            },
1270            LogGroup {
1271                name: "/aws/lambda/test-prod".to_string(),
1272                creation_time: None,
1273                stored_bytes: None,
1274                retention_days: None,
1275                log_class: None,
1276                arn: None,
1277                log_group_arn: None,
1278                deletion_protection_enabled: None,
1279            },
1280            LogGroup {
1281                name: "/aws/lambda/production".to_string(),
1282                creation_time: None,
1283                stored_bytes: None,
1284                retention_days: None,
1285                log_class: None,
1286                arn: None,
1287                log_group_arn: None,
1288                deletion_protection_enabled: None,
1289            },
1290        ];
1291
1292        // Test partial match (default)
1293        app.log_groups_state.log_groups.filter = "test".to_string();
1294        app.log_groups_state.exact_match = false;
1295        let filtered = filtered_log_groups(&app);
1296        assert_eq!(filtered.len(), 2); // Matches "test" and "test-prod"
1297
1298        // Test exact match
1299        app.log_groups_state.exact_match = true;
1300        let filtered = filtered_log_groups(&app);
1301        assert_eq!(filtered.len(), 0); // No exact match for "test"
1302
1303        // Test exact match with full name
1304        app.log_groups_state.log_groups.filter = "/aws/lambda/test".to_string();
1305        let filtered = filtered_log_groups(&app);
1306        assert_eq!(filtered.len(), 1); // Exact match
1307        assert_eq!(filtered[0].name, "/aws/lambda/test");
1308    }
1309
1310    #[test]
1311    fn test_exact_match_filters_log_streams() {
1312        use crate::app::{Service, ViewMode};
1313        use rusticity_core::LogStream;
1314
1315        let mut app = test_app();
1316        app.current_service = Service::CloudWatchLogGroups;
1317        app.view_mode = ViewMode::Detail;
1318
1319        // Add test log streams
1320        app.log_groups_state.log_streams = vec![
1321            LogStream {
1322                name: "2024/01/01/stream1".to_string(),
1323                creation_time: None,
1324                last_event_time: None,
1325            },
1326            LogStream {
1327                name: "2024/01/01/stream1-backup".to_string(),
1328                creation_time: None,
1329                last_event_time: None,
1330            },
1331            LogStream {
1332                name: "2024/01/02/stream2".to_string(),
1333                creation_time: None,
1334                last_event_time: None,
1335            },
1336        ];
1337
1338        // Test partial match (default)
1339        app.log_groups_state.stream_filter = "stream1".to_string();
1340        app.log_groups_state.exact_match = false;
1341        let filtered = filtered_log_streams(&app);
1342        assert_eq!(filtered.len(), 2); // Matches "stream1" and "stream1-backup"
1343
1344        // Test exact match
1345        app.log_groups_state.exact_match = true;
1346        let filtered = filtered_log_streams(&app);
1347        assert_eq!(filtered.len(), 0); // No exact match for "stream1"
1348
1349        // Test exact match with full name
1350        app.log_groups_state.stream_filter = "2024/01/01/stream1".to_string();
1351        let filtered = filtered_log_streams(&app);
1352        assert_eq!(filtered.len(), 1); // Exact match
1353        assert_eq!(filtered[0].name, "2024/01/01/stream1");
1354    }
1355
1356    #[test]
1357    fn test_exact_match_checkbox_focus_cycle() {
1358        use crate::app::{Service, ViewMode};
1359        use crate::keymap::{Action, Mode};
1360
1361        let mut app = test_app();
1362        app.current_service = Service::CloudWatchLogGroups;
1363        app.view_mode = ViewMode::Detail;
1364        app.mode = Mode::FilterInput;
1365        app.log_groups_state.detail_tab = DetailTab::LogStreams;
1366        app.log_groups_state.input_focus = InputFocus::Filter;
1367
1368        // Cycle to exact match checkbox
1369        app.handle_action(Action::NextFilterFocus);
1370        assert_eq!(
1371            app.log_groups_state.input_focus,
1372            InputFocus::Checkbox("ExactMatch")
1373        );
1374
1375        // Space should toggle exact match
1376        assert!(!app.log_groups_state.exact_match);
1377        app.handle_action(Action::ToggleFilterCheckbox);
1378        assert!(app.log_groups_state.exact_match);
1379
1380        // Cycle to next control
1381        app.handle_action(Action::NextFilterFocus);
1382        assert_eq!(
1383            app.log_groups_state.input_focus,
1384            InputFocus::Checkbox("ShowExpired")
1385        );
1386
1387        // Cycle back
1388        app.handle_action(Action::PrevFilterFocus);
1389        assert_eq!(
1390            app.log_groups_state.input_focus,
1391            InputFocus::Checkbox("ExactMatch")
1392        );
1393    }
1394
1395    #[test]
1396    fn test_tags_tab_in_detail_view() {
1397        use crate::app::{Service, ViewMode};
1398
1399        let mut app = test_app();
1400        app.current_service = Service::CloudWatchLogGroups;
1401        app.view_mode = ViewMode::Detail;
1402
1403        // Initially on LogStreams tab
1404        assert_eq!(app.log_groups_state.detail_tab, DetailTab::LogStreams);
1405
1406        // Cycle to Tags tab
1407        app.log_groups_state.detail_tab = app.log_groups_state.detail_tab.next();
1408        assert_eq!(app.log_groups_state.detail_tab, DetailTab::Tags);
1409
1410        // Cycle back to LogStreams
1411        app.log_groups_state.detail_tab = app.log_groups_state.detail_tab.next();
1412        assert_eq!(app.log_groups_state.detail_tab, DetailTab::LogStreams);
1413    }
1414
1415    #[test]
1416    fn test_tags_tab_preferences_cycling() {
1417        use crate::app::{Service, ViewMode};
1418        use crate::keymap::{Action, Mode};
1419
1420        let mut app = test_app();
1421        app.current_service = Service::CloudWatchLogGroups;
1422        app.view_mode = ViewMode::Detail;
1423        app.log_groups_state.detail_tab = DetailTab::Tags;
1424        app.mode = Mode::ColumnSelector;
1425        app.column_selector_index = 0;
1426
1427        // Tab from Columns to PageSize
1428        let page_size_idx = app.cw_log_tag_column_ids.len() + 2;
1429        app.handle_action(Action::NextPreferences);
1430        assert_eq!(app.column_selector_index, page_size_idx);
1431
1432        // Tab from PageSize back to Columns
1433        app.handle_action(Action::NextPreferences);
1434        assert_eq!(app.column_selector_index, 0);
1435
1436        // Shift+Tab from Columns to PageSize
1437        app.handle_action(Action::PrevPreferences);
1438        assert_eq!(app.column_selector_index, page_size_idx);
1439    }
1440
1441    #[test]
1442    fn test_tags_tab_filter_mode() {
1443        use crate::app::{Service, ViewMode};
1444        use crate::keymap::{Action, Mode};
1445
1446        let mut app = test_app();
1447        app.current_service = Service::CloudWatchLogGroups;
1448        app.service_selected = true;
1449        app.view_mode = ViewMode::Detail;
1450        app.log_groups_state.detail_tab = DetailTab::Tags;
1451        app.mode = Mode::Normal;
1452
1453        // Press 'i' to enter filter mode
1454        app.handle_action(Action::StartFilter);
1455        assert_eq!(app.mode, Mode::FilterInput);
1456        assert_eq!(app.log_groups_state.input_focus, InputFocus::Filter);
1457    }
1458
1459    #[test]
1460    fn test_log_streams_tab_filter_mode() {
1461        use crate::app::{Service, ViewMode};
1462        use crate::keymap::{Action, Mode};
1463
1464        let mut app = test_app();
1465        app.current_service = Service::CloudWatchLogGroups;
1466        app.service_selected = true;
1467        app.view_mode = ViewMode::Detail;
1468        app.log_groups_state.detail_tab = DetailTab::LogStreams;
1469        app.mode = Mode::Normal;
1470
1471        // Press 'i' to enter filter mode
1472        app.handle_action(Action::StartFilter);
1473        assert_eq!(app.mode, Mode::FilterInput);
1474        assert_eq!(app.log_groups_state.input_focus, InputFocus::Filter);
1475    }
1476
1477    #[test]
1478    fn test_detail_tab_all_matches_cyclic_enum() {
1479        // Ensure DetailTab::all() returns the same tabs as CyclicEnum::ALL
1480        let all_tabs = DetailTab::all();
1481        let cyclic_tabs: Vec<DetailTab> = DetailTab::ALL.to_vec();
1482
1483        assert_eq!(
1484            all_tabs.len(),
1485            cyclic_tabs.len(),
1486            "DetailTab::all() must return same number of tabs as CyclicEnum::ALL"
1487        );
1488
1489        for (i, tab) in all_tabs.iter().enumerate() {
1490            assert_eq!(
1491                tab, &cyclic_tabs[i],
1492                "DetailTab::all() must return tabs in same order as CyclicEnum::ALL"
1493            );
1494        }
1495    }
1496
1497    #[test]
1498    fn test_show_expired_filters_streams_by_retention() {
1499        use crate::app::{Service, ViewMode};
1500        use chrono::Utc;
1501        use rusticity_core::LogGroup;
1502
1503        let mut app = test_app();
1504        app.current_service = Service::CloudWatchLogGroups;
1505        app.service_selected = true;
1506        app.view_mode = ViewMode::Detail;
1507
1508        let now = Utc::now();
1509        let retention_days = 7;
1510
1511        // Add log group with 7-day retention
1512        app.log_groups_state.log_groups.items = vec![LogGroup {
1513            name: "/aws/lambda/test".to_string(),
1514            creation_time: None,
1515            stored_bytes: None,
1516            retention_days: Some(retention_days),
1517            log_class: None,
1518            arn: None,
1519            log_group_arn: None,
1520            deletion_protection_enabled: None,
1521        }];
1522
1523        // Add streams: one recent, one expired
1524        app.log_groups_state.log_streams = vec![
1525            LogStream {
1526                name: "recent-stream".to_string(),
1527                creation_time: None,
1528                last_event_time: Some(now - chrono::Duration::days(3)),
1529            },
1530            LogStream {
1531                name: "expired-stream".to_string(),
1532                creation_time: None,
1533                last_event_time: Some(now - chrono::Duration::days(10)),
1534            },
1535        ];
1536
1537        // With show_expired = false, should only show recent stream
1538        app.log_groups_state.show_expired = false;
1539        let filtered = filtered_log_streams(&app);
1540        assert_eq!(filtered.len(), 1);
1541        assert_eq!(filtered[0].name, "recent-stream");
1542
1543        // With show_expired = true, should show both
1544        app.log_groups_state.show_expired = true;
1545        let filtered = filtered_log_streams(&app);
1546        assert_eq!(filtered.len(), 2);
1547    }
1548
1549    #[test]
1550    fn test_show_expired_no_retention_shows_all() {
1551        use crate::app::{Service, ViewMode};
1552        use chrono::Utc;
1553        use rusticity_core::LogGroup;
1554
1555        let mut app = test_app();
1556        app.current_service = Service::CloudWatchLogGroups;
1557        app.service_selected = true;
1558        app.view_mode = ViewMode::Detail;
1559
1560        let now = Utc::now();
1561
1562        // Add log group with no retention (None)
1563        app.log_groups_state.log_groups.items = vec![LogGroup {
1564            name: "/aws/lambda/test".to_string(),
1565            creation_time: None,
1566            stored_bytes: None,
1567            retention_days: None,
1568            log_class: None,
1569            arn: None,
1570            log_group_arn: None,
1571            deletion_protection_enabled: None,
1572        }];
1573
1574        app.log_groups_state.log_streams = vec![
1575            LogStream {
1576                name: "stream1".to_string(),
1577                creation_time: None,
1578                last_event_time: Some(now - chrono::Duration::days(100)),
1579            },
1580            LogStream {
1581                name: "stream2".to_string(),
1582                creation_time: None,
1583                last_event_time: Some(now - chrono::Duration::days(1)),
1584            },
1585        ];
1586
1587        // With no retention, all streams shown regardless of show_expired
1588        app.log_groups_state.show_expired = false;
1589        let filtered = filtered_log_streams(&app);
1590        assert_eq!(filtered.len(), 2);
1591    }
1592
1593    #[test]
1594    fn test_log_streams_pagination_right_arrow_increments_by_one_page() {
1595        // Regression: handle_page_down was adding page_size (20) to stream_current_page
1596        // which is a PAGE INDEX, not an item offset. Result: page 1 → page 21 instead of page 2.
1597        use crate::app::ViewMode;
1598        use crate::app::{App, Service};
1599        use crate::common::InputFocus;
1600        use crate::keymap::Mode;
1601
1602        let mut app = App::new_without_client("default".to_string(), None);
1603        app.current_service = Service::CloudWatchLogGroups;
1604        app.service_selected = true;
1605        app.view_mode = ViewMode::Detail;
1606        app.mode = Mode::FilterInput;
1607        app.log_groups_state.input_focus = InputFocus::Pagination;
1608        app.log_groups_state.stream_page_size = 20;
1609        app.log_groups_state.stream_current_page = 0;
1610        app.log_groups_state.selected_stream = 0;
1611
1612        // Create 60 streams (3 pages of 20)
1613        app.log_groups_state.log_streams = (0..60)
1614            .map(|i| rusticity_core::types::LogStream {
1615                name: format!("stream-{}", i),
1616                creation_time: None,
1617                last_event_time: None,
1618            })
1619            .collect();
1620
1621        crate::cw::actions::logs_page_down_filter_input(&mut app);
1622
1623        assert_eq!(
1624            app.log_groups_state.stream_current_page, 1,
1625            "Right arrow must advance to page 2 (index 1), not page 21"
1626        );
1627        assert_eq!(app.log_groups_state.selected_stream, 20);
1628
1629        crate::cw::actions::logs_page_up_filter_input(&mut app);
1630        assert_eq!(
1631            app.log_groups_state.stream_current_page, 0,
1632            "Left arrow must go back to page 1 (index 0)"
1633        );
1634        assert_eq!(app.log_groups_state.selected_stream, 0);
1635    }
1636
1637    #[test]
1638    fn test_log_groups_list_tab_cycles_only_filter_and_pagination() {
1639        // Regression: FILTER_CONTROLS had 4 entries (Filter, ExactMatch, ShowExpired, Pagination).
1640        // In list view, Tab was cycling through ExactMatch/ShowExpired which are
1641        // only relevant in the streams detail view and not visible in the list filter bar.
1642        use crate::app::ViewMode;
1643        use crate::app::{App, Service};
1644        use crate::common::InputFocus;
1645        use crate::keymap::Mode;
1646
1647        let mut app = App::new_without_client("default".to_string(), None);
1648        app.current_service = Service::CloudWatchLogGroups;
1649        app.service_selected = true;
1650        app.view_mode = ViewMode::List;
1651        app.mode = Mode::FilterInput;
1652        app.log_groups_state.input_focus = InputFocus::Filter;
1653
1654        crate::cw::actions::logs_next_filter_focus(&mut app);
1655        assert_eq!(
1656            app.log_groups_state.input_focus,
1657            InputFocus::Pagination,
1658            "In list view, Tab from Filter must go directly to Pagination (skip ExactMatch/ShowExpired)"
1659        );
1660
1661        crate::cw::actions::logs_next_filter_focus(&mut app);
1662        assert_eq!(
1663            app.log_groups_state.input_focus,
1664            InputFocus::Filter,
1665            "Tab from Pagination must wrap back to Filter"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_log_groups_down_arrow_stays_within_page_and_updates_selection() {
1671        // Regression: next_item adjusted scroll_offset by 1 (not page-aligned), causing
1672        // selected_index = selected % page_size to point to the wrong visible row.
1673        // After the fix, scroll_offset is snapped to page boundary so selected_index
1674        // equals selected - scroll_offset correctly.
1675        use crate::app::ViewMode;
1676        use crate::app::{App, Service};
1677        use crate::common::PageSize;
1678        use rusticity_core::types::LogGroup;
1679
1680        let mut app = App::new_without_client("default".to_string(), None);
1681        app.current_service = Service::CloudWatchLogGroups;
1682        app.service_selected = true;
1683        app.view_mode = ViewMode::List;
1684        app.log_groups_state.log_groups.page_size = PageSize::Ten;
1685
1686        // Create 21 log groups
1687        app.log_groups_state.log_groups.items = (0..21)
1688            .map(|i| LogGroup {
1689                name: format!("/aws/group/{}", i),
1690                stored_bytes: Some(0),
1691                retention_days: None,
1692                log_class: None,
1693                arn: None,
1694                log_group_arn: None,
1695                deletion_protection_enabled: None,
1696                creation_time: None,
1697            })
1698            .collect();
1699
1700        // Simulate page jump to page 3 (selected=20, scroll_offset=20)
1701        app.log_groups_state.log_groups.selected = 20;
1702        app.log_groups_state.log_groups.scroll_offset = 20;
1703
1704        // Down arrow — already at last item, should stay
1705        crate::cw::actions::logs_next_item(&mut app);
1706        assert_eq!(
1707            app.log_groups_state.log_groups.selected, 20,
1708            "Can't go past last item"
1709        );
1710        assert_eq!(
1711            app.log_groups_state.log_groups.scroll_offset, 20,
1712            "scroll_offset must stay page-aligned"
1713        );
1714
1715        // Navigate back to page 2 via up arrow from item 20 to item 19
1716        app.log_groups_state.log_groups.selected = 20;
1717        app.log_groups_state.log_groups.scroll_offset = 20;
1718        crate::cw::actions::logs_prev_item(&mut app);
1719        assert_eq!(app.log_groups_state.log_groups.selected, 19);
1720        // scroll_offset follows selection (non-page-aligned viewport scrolling is OK)
1721        let scroll = app.log_groups_state.log_groups.scroll_offset;
1722        assert!(
1723            scroll <= 19,
1724            "scroll_offset {scroll} must be <= selected 19"
1725        );
1726    }
1727}