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