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