Skip to main content

rusticity_term/ui/
cfn.rs

1use crate::app::App;
2use crate::cfn::{format_status, Column as CfnColumn, Stack as CfnStack};
3use crate::common::{
4    filter_by_fields, format_iso_timestamp, render_dropdown, render_pagination_text,
5    translate_column, ColumnId, CyclicEnum, InputFocus, SortDirection, UTC_TIMESTAMP_WIDTH,
6};
7use crate::keymap::Mode;
8use crate::table::TableState;
9use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
10use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
11use crate::ui::{
12    calculate_dynamic_height, format_title, labeled_field, render_fields_with_dynamic_columns,
13    render_json_highlighted, render_tabs, render_template_highlighted, titled_block,
14};
15use ratatui::{prelude::*, widgets::*};
16use rusticity_core::cfn::{StackChangeSet, StackEvent, StackOutput, StackParameter, StackResource};
17use std::collections::HashSet;
18
19pub const STATUS_FILTER: InputFocus = InputFocus::Dropdown("StatusFilter");
20pub const VIEW_NESTED: InputFocus = InputFocus::Checkbox("ViewNested");
21
22impl State {
23    pub const FILTER_CONTROLS: [InputFocus; 4] = [
24        InputFocus::Filter,
25        STATUS_FILTER,
26        VIEW_NESTED,
27        InputFocus::Pagination,
28    ];
29
30    pub const PARAMETERS_FILTER_CONTROLS: [InputFocus; 2] =
31        [InputFocus::Filter, InputFocus::Pagination];
32
33    pub const OUTPUTS_FILTER_CONTROLS: [InputFocus; 2] =
34        [InputFocus::Filter, InputFocus::Pagination];
35
36    pub const RESOURCES_FILTER_CONTROLS: [InputFocus; 2] =
37        [InputFocus::Filter, InputFocus::Pagination];
38
39    pub const EVENTS_FILTER_CONTROLS: [InputFocus; 2] =
40        [InputFocus::Filter, InputFocus::Pagination];
41
42    pub const CHANGE_SETS_FILTER_CONTROLS: [InputFocus; 2] =
43        [InputFocus::Filter, InputFocus::Pagination];
44}
45
46/// Which view is active on the Events tab.
47#[derive(Debug, Clone, Copy, PartialEq, Default)]
48pub enum EventsView {
49    #[default]
50    Table,
51    Timeline,
52}
53
54pub struct State {
55    pub table: TableState<CfnStack>,
56    pub input_focus: InputFocus,
57    pub status_filter: StatusFilter,
58    pub view_nested: bool,
59    pub current_stack: Option<String>,
60    pub detail_tab: DetailTab,
61    pub overview_scroll: u16,
62    pub sort_column: CfnColumn,
63    pub sort_direction: SortDirection,
64    pub template_body: String,
65    pub template_scroll: usize,
66    pub parameters: TableState<StackParameter>,
67    pub parameters_input_focus: InputFocus,
68    pub outputs: TableState<StackOutput>,
69    pub outputs_input_focus: InputFocus,
70    pub tags: TableState<(String, String)>,
71    pub policy_scroll: usize,
72    /// Tracks expanded items for hierarchical views (Resources, Events tabs).
73    /// Keys are resource IDs or logical resource names that are currently expanded.
74    pub expanded_items: HashSet<String>,
75    pub resources: TableState<StackResource>,
76    pub resources_input_focus: InputFocus,
77    pub events: TableState<StackEvent>,
78    pub events_input_focus: InputFocus,
79    pub events_view: EventsView,
80    pub change_sets: TableState<StackChangeSet>,
81    pub change_sets_input_focus: InputFocus,
82}
83
84impl Default for State {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl State {
91    pub fn new() -> Self {
92        Self {
93            table: TableState::new(),
94            input_focus: InputFocus::Filter,
95            status_filter: StatusFilter::All,
96            view_nested: false,
97            current_stack: None,
98            detail_tab: DetailTab::StackInfo,
99            overview_scroll: 0,
100            sort_column: CfnColumn::CreatedTime,
101            sort_direction: SortDirection::Desc,
102            template_body: String::new(),
103            template_scroll: 0,
104            parameters: TableState::new(),
105            parameters_input_focus: InputFocus::Filter,
106            outputs: TableState::new(),
107            outputs_input_focus: InputFocus::Filter,
108            tags: TableState::new(),
109            policy_scroll: 0,
110            expanded_items: HashSet::new(),
111            resources: TableState::new(),
112            resources_input_focus: InputFocus::Filter,
113            events: TableState::new(),
114            events_input_focus: InputFocus::Filter,
115            events_view: EventsView::Table,
116            change_sets: TableState::new(),
117            change_sets_input_focus: InputFocus::Filter,
118        }
119    }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub enum StatusFilter {
124    All,
125    Active,
126    Complete,
127    Failed,
128    Deleted,
129    InProgress,
130}
131
132impl StatusFilter {
133    pub fn name(&self) -> &'static str {
134        match self {
135            StatusFilter::All => "All",
136            StatusFilter::Active => "Active",
137            StatusFilter::Complete => "Complete",
138            StatusFilter::Failed => "Failed",
139            StatusFilter::Deleted => "Deleted",
140            StatusFilter::InProgress => "In progress",
141        }
142    }
143
144    pub fn all() -> Vec<StatusFilter> {
145        vec![
146            StatusFilter::All,
147            StatusFilter::Active,
148            StatusFilter::Complete,
149            StatusFilter::Failed,
150            StatusFilter::Deleted,
151            StatusFilter::InProgress,
152        ]
153    }
154}
155
156impl CyclicEnum for StatusFilter {
157    const ALL: &'static [Self] = &[
158        Self::All,
159        Self::Active,
160        Self::Complete,
161        Self::Failed,
162        Self::Deleted,
163        Self::InProgress,
164    ];
165}
166
167impl StatusFilter {
168    pub fn matches(&self, status: &str) -> bool {
169        match self {
170            StatusFilter::All => true,
171            StatusFilter::Active => {
172                !status.contains("DELETE")
173                    && !status.contains("COMPLETE")
174                    && !status.contains("FAILED")
175            }
176            StatusFilter::Complete => status.contains("COMPLETE") && !status.contains("DELETE"),
177            StatusFilter::Failed => status.contains("FAILED"),
178            StatusFilter::Deleted => status.contains("DELETE"),
179            StatusFilter::InProgress => status.contains("IN_PROGRESS"),
180        }
181    }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq)]
185pub enum DetailTab {
186    StackInfo,
187    Events,
188    Resources,
189    Outputs,
190    Parameters,
191    Template,
192    ChangeSets,
193    GitSync,
194}
195
196impl CyclicEnum for DetailTab {
197    const ALL: &'static [Self] = &[
198        Self::StackInfo,
199        Self::Events,
200        Self::Resources,
201        Self::Outputs,
202        Self::Parameters,
203        Self::Template,
204        Self::ChangeSets,
205        Self::GitSync,
206    ];
207}
208
209impl DetailTab {
210    pub fn name(&self) -> &'static str {
211        match self {
212            DetailTab::StackInfo => "Stack info",
213            DetailTab::Events => "Events",
214            DetailTab::Resources => "Resources",
215            DetailTab::Outputs => "Outputs",
216            DetailTab::Parameters => "Parameters",
217            DetailTab::Template => "Template",
218            DetailTab::ChangeSets => "Change sets",
219            DetailTab::GitSync => "Git sync",
220        }
221    }
222
223    pub fn all() -> Vec<DetailTab> {
224        vec![
225            DetailTab::StackInfo,
226            DetailTab::Events,
227            DetailTab::Resources,
228            DetailTab::Outputs,
229            DetailTab::Parameters,
230            DetailTab::Template,
231            DetailTab::ChangeSets,
232            DetailTab::GitSync,
233        ]
234    }
235
236    pub fn allows_preferences(&self) -> bool {
237        matches!(
238            self,
239            DetailTab::StackInfo
240                | DetailTab::Events
241                | DetailTab::Parameters
242                | DetailTab::Outputs
243                | DetailTab::Resources
244                | DetailTab::ChangeSets
245        )
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq)]
250pub enum ResourceColumn {
251    LogicalId,
252    PhysicalId,
253    Type,
254    Status,
255    Module,
256}
257
258impl ResourceColumn {
259    pub fn id(&self) -> &'static str {
260        match self {
261            ResourceColumn::LogicalId => "cfn.resource.logical_id",
262            ResourceColumn::PhysicalId => "cfn.resource.physical_id",
263            ResourceColumn::Type => "cfn.resource.type",
264            ResourceColumn::Status => "cfn.resource.status",
265            ResourceColumn::Module => "cfn.resource.module",
266        }
267    }
268
269    pub fn default_name(&self) -> &'static str {
270        match self {
271            ResourceColumn::LogicalId => "Logical ID",
272            ResourceColumn::PhysicalId => "Physical ID",
273            ResourceColumn::Type => "Type",
274            ResourceColumn::Status => "Status",
275            ResourceColumn::Module => "Module",
276        }
277    }
278
279    pub fn all() -> Vec<ResourceColumn> {
280        vec![
281            ResourceColumn::LogicalId,
282            ResourceColumn::PhysicalId,
283            ResourceColumn::Type,
284            ResourceColumn::Status,
285            ResourceColumn::Module,
286        ]
287    }
288
289    pub fn from_id(id: &str) -> Option<ResourceColumn> {
290        match id {
291            "cfn.resource.logical_id" => Some(ResourceColumn::LogicalId),
292            "cfn.resource.physical_id" => Some(ResourceColumn::PhysicalId),
293            "cfn.resource.type" => Some(ResourceColumn::Type),
294            "cfn.resource.status" => Some(ResourceColumn::Status),
295            "cfn.resource.module" => Some(ResourceColumn::Module),
296            _ => None,
297        }
298    }
299}
300
301pub fn resource_column_ids() -> Vec<ColumnId> {
302    ResourceColumn::all().iter().map(|c| c.id()).collect()
303}
304
305pub fn event_column_ids() -> Vec<ColumnId> {
306    EventColumn::all().iter().map(|c| c.id()).collect()
307}
308
309/// Default visible columns for the Events table.
310/// OperationId IS visible by default.
311/// HookInvocationCount and Type are NOT visible by default.
312/// PhysicalId and ClientRequestToken are NOT visible by default.
313pub fn event_visible_column_ids() -> Vec<ColumnId> {
314    [
315        EventColumn::OperationId,
316        EventColumn::Timestamp,
317        EventColumn::LogicalId,
318        EventColumn::Status,
319        EventColumn::DetailedStatus,
320        EventColumn::StatusReason,
321    ]
322    .iter()
323    .map(|c| c.id())
324    .collect()
325}
326
327pub fn filtered_events(app: &App) -> Vec<&StackEvent> {
328    filter_by_fields(
329        &app.cfn_state.events.items,
330        &app.cfn_state.events.filter,
331        |e| {
332            vec![
333                &e.logical_id,
334                &e.status,
335                &e.status_reason,
336                &e.resource_type,
337                &e.physical_id,
338            ]
339        },
340    )
341}
342
343pub fn change_set_column_ids() -> Vec<ColumnId> {
344    ChangeSetColumn::all().iter().map(|c| c.id()).collect()
345}
346
347/// Default visible columns: Root change set ID and Parent change set ID hidden.
348pub fn change_set_visible_column_ids() -> Vec<ColumnId> {
349    [
350        ChangeSetColumn::Name,
351        ChangeSetColumn::CreatedTime,
352        ChangeSetColumn::Status,
353        ChangeSetColumn::Description,
354    ]
355    .iter()
356    .map(|c| c.id())
357    .collect()
358}
359
360pub fn filtered_change_sets(app: &App) -> Vec<&StackChangeSet> {
361    filter_by_fields(
362        &app.cfn_state.change_sets.items,
363        &app.cfn_state.change_sets.filter,
364        |cs| vec![&cs.name, &cs.status, &cs.description],
365    )
366}
367
368pub fn filtered_cloudformation_stacks(app: &App) -> Vec<&CfnStack> {
369    filter_by_fields(
370        &app.cfn_state.table.items,
371        &app.cfn_state.table.filter,
372        |s| vec![&s.name, &s.description],
373    )
374    .into_iter()
375    .filter(|s| app.cfn_state.status_filter.matches(&s.status))
376    .collect()
377}
378
379pub fn parameter_column_ids() -> Vec<ColumnId> {
380    ParameterColumn::all().iter().map(|c| c.id()).collect()
381}
382
383pub fn output_column_ids() -> Vec<ColumnId> {
384    OutputColumn::all().iter().map(|c| c.id()).collect()
385}
386
387pub fn filtered_parameters(app: &App) -> Vec<&StackParameter> {
388    filter_by_fields(
389        &app.cfn_state.parameters.items,
390        &app.cfn_state.parameters.filter,
391        |p| vec![&p.key, &p.value, &p.resolved_value],
392    )
393}
394
395pub fn filtered_outputs(app: &App) -> Vec<&StackOutput> {
396    filter_by_fields(
397        &app.cfn_state.outputs.items,
398        &app.cfn_state.outputs.filter,
399        |o| vec![&o.key, &o.value, &o.description, &o.export_name],
400    )
401}
402
403pub fn filtered_resources(app: &App) -> Vec<&StackResource> {
404    filter_by_fields(
405        &app.cfn_state.resources.items,
406        &app.cfn_state.resources.filter,
407        |r| {
408            vec![
409                &r.logical_id,
410                &r.physical_id,
411                &r.resource_type,
412                &r.status,
413                &r.module_info,
414            ]
415        },
416    )
417}
418
419pub fn filtered_tags(app: &App) -> Vec<&(String, String)> {
420    filter_by_fields(
421        &app.cfn_state.tags.items,
422        &app.cfn_state.tags.filter,
423        |(k, v)| vec![k.as_str(), v.as_str()],
424    )
425}
426
427pub fn render_stacks(frame: &mut Frame, app: &App, area: Rect) {
428    frame.render_widget(Clear, area);
429
430    if app.cfn_state.current_stack.is_some() {
431        render_cloudformation_stack_detail(frame, app, area);
432    } else {
433        render_cloudformation_stack_list(frame, app, area);
434    }
435}
436
437pub fn render_cloudformation_stack_list(frame: &mut Frame, app: &App, area: Rect) {
438    let chunks = Layout::default()
439        .direction(Direction::Vertical)
440        .constraints([
441            Constraint::Length(3), // Filter + controls
442            Constraint::Min(0),    // Table
443        ])
444        .split(area);
445
446    // Filter line - search on left, controls on right
447    let filtered_stacks = filtered_cloudformation_stacks(app);
448    let filtered_count = filtered_stacks.len();
449
450    let placeholder = "Search by stack name";
451
452    let status_filter_text = format!("Filter status: {}", app.cfn_state.status_filter.name());
453    let view_nested_text = if app.cfn_state.view_nested {
454        "☑ View nested"
455    } else {
456        "☐ View nested"
457    };
458    let page_size = app.cfn_state.table.page_size.value();
459    let total_pages = filtered_count.div_ceil(page_size);
460    let current_page =
461        if filtered_count > 0 && app.cfn_state.table.scroll_offset + page_size >= filtered_count {
462            total_pages.saturating_sub(1)
463        } else {
464            app.cfn_state.table.scroll_offset / page_size
465        };
466    let pagination = render_pagination_text(current_page, total_pages);
467
468    render_filter_bar(
469        frame,
470        FilterConfig {
471            filter_text: &app.cfn_state.table.filter,
472            placeholder,
473            mode: app.mode,
474            is_input_focused: app.cfn_state.input_focus == InputFocus::Filter,
475            controls: vec![
476                FilterControl {
477                    text: status_filter_text.to_string(),
478                    is_focused: app.cfn_state.input_focus == STATUS_FILTER,
479                },
480                FilterControl {
481                    text: view_nested_text.to_string(),
482                    is_focused: app.cfn_state.input_focus == VIEW_NESTED,
483                },
484                FilterControl {
485                    text: pagination.clone(),
486                    is_focused: app.cfn_state.input_focus == InputFocus::Pagination,
487                },
488            ],
489            area: chunks[0],
490        },
491    );
492
493    // Table - use scroll_offset for pagination
494    let scroll_offset = app.cfn_state.table.scroll_offset;
495    let page_stacks: Vec<_> = filtered_stacks
496        .iter()
497        .skip(scroll_offset)
498        .take(page_size)
499        .collect();
500
501    // Define columns
502    let column_enums: Vec<CfnColumn> = app
503        .cfn_visible_column_ids
504        .iter()
505        .filter_map(|col_id| CfnColumn::from_id(col_id))
506        .collect();
507
508    let columns: Vec<Box<dyn Column<&CfnStack>>> =
509        column_enums.iter().map(|col| col.to_column()).collect();
510
511    let expanded_index = app.cfn_state.table.expanded_item.and_then(|idx| {
512        let scroll_offset = app.cfn_state.table.scroll_offset;
513        if idx >= scroll_offset && idx < scroll_offset + page_size {
514            Some(idx - scroll_offset)
515        } else {
516            None
517        }
518    });
519
520    let config = TableConfig {
521        items: page_stacks,
522        selected_index: app.cfn_state.table.selected % app.cfn_state.table.page_size.value(),
523        expanded_index,
524        columns: &columns,
525        sort_column: app.cfn_state.sort_column.default_name(),
526        sort_direction: app.cfn_state.sort_direction,
527        title: format_title(&format!("Stacks ({})", filtered_count)),
528        area: chunks[1],
529        get_expanded_content: Some(Box::new(|stack: &&CfnStack| {
530            expanded_from_columns(&columns, stack)
531        })),
532        is_active: app.mode != Mode::FilterInput,
533    };
534
535    render_table(frame, config);
536
537    // Render dropdown for StatusFilter when focused (after table so it appears on top)
538    if app.mode == Mode::FilterInput && app.cfn_state.input_focus == STATUS_FILTER {
539        let filter_names: Vec<&str> = StatusFilter::all().iter().map(|f| f.name()).collect();
540        let selected_idx = StatusFilter::all()
541            .iter()
542            .position(|f| *f == app.cfn_state.status_filter)
543            .unwrap_or(0);
544        let view_nested_width = " ☑ View nested ".len() as u16;
545        let controls_after = view_nested_width + 3 + pagination.len() as u16 + 3;
546        render_dropdown(
547            frame,
548            &filter_names,
549            selected_idx,
550            chunks[0],
551            controls_after,
552        );
553    }
554}
555
556pub fn render_cloudformation_stack_detail(frame: &mut Frame, app: &App, area: Rect) {
557    let stack_name = app.cfn_state.current_stack.as_ref().unwrap();
558
559    // Find the stack
560    let stack = app
561        .cfn_state
562        .table
563        .items
564        .iter()
565        .find(|s| &s.name == stack_name);
566
567    if stack.is_none() {
568        let paragraph = Paragraph::new("Stack not found").block(titled_block("Error"));
569        frame.render_widget(paragraph, area);
570        return;
571    }
572
573    let stack = stack.unwrap();
574
575    let chunks = Layout::default()
576        .direction(Direction::Vertical)
577        .constraints([
578            Constraint::Length(1), // Tabs
579            Constraint::Min(0),    // Content
580        ])
581        .split(area);
582
583    // Render tabs
584    let tabs: Vec<_> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
585    render_tabs(frame, chunks[0], &tabs, &app.cfn_state.detail_tab);
586
587    // Render content based on selected tab
588    match app.cfn_state.detail_tab {
589        DetailTab::StackInfo => {
590            render_stack_info(frame, app, stack, chunks[1]);
591        }
592        DetailTab::GitSync => {
593            render_git_sync(frame, app, stack, chunks[1]);
594        }
595        DetailTab::Template => {
596            render_template_highlighted(
597                frame,
598                chunks[1],
599                &app.cfn_state.template_body,
600                app.cfn_state.template_scroll,
601                " Template ",
602                true,
603            );
604        }
605        DetailTab::Parameters => {
606            render_parameters(frame, app, chunks[1]);
607        }
608        DetailTab::Outputs => {
609            render_outputs(frame, app, chunks[1]);
610        }
611        DetailTab::Resources => {
612            render_resources(frame, app, chunks[1]);
613        }
614        DetailTab::Events => {
615            render_events(frame, app, chunks[1]);
616        }
617        DetailTab::ChangeSets => {
618            render_change_sets(frame, app, chunks[1]);
619        }
620    }
621}
622
623pub fn render_stack_info(frame: &mut Frame, app: &App, stack: &CfnStack, area: Rect) {
624    let (formatted_status, _status_color) = format_status(&stack.status);
625
626    // Overview section
627    let fields = vec![
628        (
629            "Stack ID",
630            if stack.stack_id.is_empty() {
631                "-"
632            } else {
633                &stack.stack_id
634            },
635        ),
636        (
637            "Description",
638            if stack.description.is_empty() {
639                "-"
640            } else {
641                &stack.description
642            },
643        ),
644        ("Status", &formatted_status),
645        (
646            "Detailed status",
647            if stack.detailed_status.is_empty() {
648                "-"
649            } else {
650                &stack.detailed_status
651            },
652        ),
653        (
654            "Status reason",
655            if stack.status_reason.is_empty() {
656                "-"
657            } else {
658                &stack.status_reason
659            },
660        ),
661        (
662            "Root stack",
663            if stack.root_stack.is_empty() {
664                "-"
665            } else {
666                &stack.root_stack
667            },
668        ),
669        (
670            "Parent stack",
671            if stack.parent_stack.is_empty() {
672                "-"
673            } else {
674                &stack.parent_stack
675            },
676        ),
677        (
678            "Created time",
679            if stack.created_time.is_empty() {
680                "-"
681            } else {
682                &stack.created_time
683            },
684        ),
685        (
686            "Updated time",
687            if stack.updated_time.is_empty() {
688                "-"
689            } else {
690                &stack.updated_time
691            },
692        ),
693        (
694            "Deleted time",
695            if stack.deleted_time.is_empty() {
696                "-"
697            } else {
698                &stack.deleted_time
699            },
700        ),
701        (
702            "Drift status",
703            if stack.drift_status.is_empty() {
704                "-"
705            } else if stack.drift_status == "NOT_CHECKED" || stack.drift_status == "NotChecked" {
706                "⭕ NOT CHECKED"
707            } else {
708                &stack.drift_status
709            },
710        ),
711        (
712            "Last drift check time",
713            if stack.last_drift_check_time.is_empty() {
714                "-"
715            } else {
716                &stack.last_drift_check_time
717            },
718        ),
719        (
720            "Termination protection",
721            if stack.termination_protection {
722                "Activated"
723            } else {
724                "Disabled"
725            },
726        ),
727        (
728            "IAM role",
729            if stack.iam_role.is_empty() {
730                "-"
731            } else {
732                &stack.iam_role
733            },
734        ),
735    ];
736    let overview_height = calculate_dynamic_height(
737        &fields
738            .iter()
739            .map(|(label, value)| labeled_field(label, *value))
740            .collect::<Vec<_>>(),
741        area.width.saturating_sub(4),
742    ) + 2;
743
744    // Tags section - use table with filter
745    let tags_height = 12; // Fixed height for tags table
746
747    // Stack policy section - render with scrolling like template
748    let policy_height = 15; // Fixed height for policy section
749
750    // Rollback configuration section
751    let rollback_lines: Vec<Line> = {
752        let mut lines = vec![labeled_field(
753            "Monitoring time",
754            if stack.rollback_monitoring_time.is_empty() {
755                "-"
756            } else {
757                &stack.rollback_monitoring_time
758            },
759        )];
760
761        if stack.rollback_alarms.is_empty() {
762            lines.push(Line::from("CloudWatch alarm ARN: No alarms configured"));
763        } else {
764            for alarm in &stack.rollback_alarms {
765                lines.push(Line::from(format!("CloudWatch alarm ARN: {}", alarm)));
766            }
767        }
768        lines
769    };
770    let rollback_height =
771        calculate_dynamic_height(&rollback_lines, area.width.saturating_sub(4)) + 2;
772
773    // Notification options section
774    let notification_lines = if stack.notification_arns.is_empty() {
775        vec!["SNS topic ARN: No notifications configured".to_string()]
776    } else {
777        stack
778            .notification_arns
779            .iter()
780            .map(|arn| format!("SNS topic ARN: {}", arn))
781            .collect()
782    };
783    let notification_height = notification_lines.len() as u16 + 2; // +2 for borders
784
785    // Split into sections with calculated heights
786    let sections = Layout::default()
787        .direction(Direction::Vertical)
788        .constraints([
789            Constraint::Length(overview_height),
790            Constraint::Length(tags_height),
791            Constraint::Length(policy_height),
792            Constraint::Length(rollback_height),
793            Constraint::Length(notification_height),
794            Constraint::Min(0), // Remaining space
795        ])
796        .split(area);
797
798    // Render overview
799    let overview_lines: Vec<_> = fields
800        .iter()
801        .map(|(label, value)| labeled_field(label, *value))
802        .collect();
803
804    let overview_block = titled_block("Overview");
805    let overview_inner = overview_block.inner(sections[0]);
806    frame.render_widget(overview_block, sections[0]);
807    render_fields_with_dynamic_columns(frame, overview_inner, overview_lines);
808
809    // Render tags table
810    render_tags(frame, app, sections[1]);
811
812    // Render stack policy with scrolling
813    let policy_text = if stack.stack_policy.is_empty() {
814        "No stack policy".to_string()
815    } else {
816        stack.stack_policy.clone()
817    };
818    render_json_highlighted(
819        frame,
820        sections[2],
821        &policy_text,
822        app.cfn_state.policy_scroll,
823        " Stack policy ",
824        true,
825    );
826
827    // Render rollback configuration
828    let rollback_block = titled_block("Rollback configuration");
829    let rollback_inner = rollback_block.inner(sections[3]);
830    frame.render_widget(rollback_block, sections[3]);
831    render_fields_with_dynamic_columns(frame, rollback_inner, rollback_lines);
832
833    // Render notification options
834    let notifications = Paragraph::new(notification_lines.join("\n"))
835        .block(titled_block("Notification options"))
836        .wrap(Wrap { trim: true });
837    frame.render_widget(notifications, sections[4]);
838}
839
840fn render_tags(frame: &mut Frame, app: &App, area: Rect) {
841    let chunks = Layout::default()
842        .direction(Direction::Vertical)
843        .constraints([Constraint::Length(3), Constraint::Min(0)])
844        .split(area);
845
846    let filtered: Vec<&(String, String)> = filtered_tags(app);
847    let filtered_count = filtered.len();
848
849    render_filter_bar(
850        frame,
851        FilterConfig {
852            filter_text: &app.cfn_state.tags.filter,
853            placeholder: "Search tags",
854            mode: app.mode,
855            is_input_focused: false,
856            controls: vec![],
857            area: chunks[0],
858        },
859    );
860
861    let page_size = app.cfn_state.tags.page_size.value();
862    let page_start = app.cfn_state.tags.scroll_offset;
863    let page_end = (page_start + page_size).min(filtered_count);
864    let page_tags: Vec<_> = filtered[page_start..page_end].to_vec();
865
866    let columns: Vec<Box<dyn Column<(String, String)>>> =
867        vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
868
869    let expanded_index = app.cfn_state.tags.expanded_item.and_then(|idx| {
870        let scroll_offset = app.cfn_state.tags.scroll_offset;
871        if idx >= scroll_offset && idx < scroll_offset + page_size {
872            Some(idx - scroll_offset)
873        } else {
874            None
875        }
876    });
877
878    let config = TableConfig {
879        items: page_tags,
880        selected_index: app.cfn_state.tags.selected % page_size,
881        expanded_index,
882        columns: &columns,
883        sort_column: "Key",
884        sort_direction: SortDirection::Asc,
885        title: format_title(&format!("Tags ({})", filtered_count)),
886        area: chunks[1],
887        get_expanded_content: Some(Box::new(|tag: &(String, String)| {
888            expanded_from_columns(&columns, tag)
889        })),
890        is_active: true,
891    };
892
893    render_table(frame, config);
894}
895
896fn render_git_sync(frame: &mut Frame, _app: &App, _stack: &CfnStack, area: Rect) {
897    let fields = [
898        ("Repository", "-"),
899        ("Deployment file path", "-"),
900        ("Git sync", "-"),
901        ("Repository provider", "-"),
902        ("Repository sync status", "-"),
903        ("Provisioning status", "-"),
904        ("Branch", "-"),
905        ("Repository sync status message", "-"),
906    ];
907
908    let lines: Vec<Line> = fields
909        .iter()
910        .map(|&(label, value)| labeled_field(label, value))
911        .collect();
912
913    let git_sync_height = calculate_dynamic_height(&lines, area.width.saturating_sub(4)) + 2;
914
915    let sections = Layout::default()
916        .direction(Direction::Vertical)
917        .constraints([Constraint::Length(git_sync_height), Constraint::Min(0)])
918        .split(area);
919
920    let block = titled_block("Git sync");
921    let inner = block.inner(sections[0]);
922    frame.render_widget(block, sections[0]);
923    render_fields_with_dynamic_columns(frame, inner, lines);
924}
925
926#[cfg(test)]
927mod tests {
928    use super::{filtered_tags, State};
929    use crate::app::App;
930
931    fn test_app() -> App {
932        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
933    }
934
935    #[test]
936    fn test_drift_status_not_checked_formatting() {
937        let drift_status = "NOT_CHECKED";
938        let formatted = if drift_status == "NOT_CHECKED" {
939            "⭕ NOT CHECKED"
940        } else {
941            drift_status
942        };
943        assert_eq!(formatted, "⭕ NOT CHECKED");
944    }
945
946    #[test]
947    fn test_drift_status_not_checked_pascal_case() {
948        let drift_status = "NotChecked";
949        let formatted = if drift_status == "NOT_CHECKED" || drift_status == "NotChecked" {
950            "⭕ NOT CHECKED"
951        } else {
952            drift_status
953        };
954        assert_eq!(formatted, "⭕ NOT CHECKED");
955    }
956
957    #[test]
958    fn test_drift_status_other_values() {
959        let drift_status = "IN_SYNC";
960        let formatted = if drift_status == "NOT_CHECKED" {
961            "⭕ NOT CHECKED"
962        } else {
963            drift_status
964        };
965        assert_eq!(formatted, "IN_SYNC");
966    }
967
968    #[test]
969    fn test_git_sync_renders_all_fields() {
970        use crate::cfn::Stack;
971        let stack = Stack {
972            name: "test-stack".to_string(),
973            stack_id: "id".to_string(),
974            status: "CREATE_COMPLETE".to_string(),
975            created_time: String::new(),
976            updated_time: String::new(),
977            deleted_time: String::new(),
978            drift_status: String::new(),
979            last_drift_check_time: String::new(),
980            status_reason: String::new(),
981            description: String::new(),
982            detailed_status: String::new(),
983            root_stack: String::new(),
984            parent_stack: String::new(),
985            termination_protection: false,
986            iam_role: String::new(),
987            tags: Vec::new(),
988            stack_policy: String::new(),
989            rollback_monitoring_time: String::new(),
990            rollback_alarms: Vec::new(),
991            notification_arns: Vec::new(),
992        };
993
994        // Verify the fields are defined
995        let fields = [
996            "Repository",
997            "Deployment file path",
998            "Git sync",
999            "Repository provider",
1000            "Repository sync status",
1001            "Provisioning status",
1002            "Branch",
1003            "Repository sync status message",
1004        ];
1005
1006        assert_eq!(fields.len(), 8);
1007        assert_eq!(stack.name, "test-stack");
1008    }
1009
1010    #[test]
1011    fn test_git_sync_uses_dynamic_columns() {
1012        // Git sync has 8 labeled fields — with dynamic columns the height depends
1013        // on how many columns fit, not a fixed block_height_for value.
1014        use crate::ui::calculate_dynamic_height;
1015        use ratatui::text::Line;
1016
1017        let fields = [
1018            ("Repository", "-"),
1019            ("Deployment file path", "-"),
1020            ("Git sync", "-"),
1021            ("Repository provider", "-"),
1022            ("Repository sync status", "-"),
1023            ("Provisioning status", "-"),
1024            ("Branch", "-"),
1025            ("Repository sync status message", "-"),
1026        ];
1027
1028        let lines: Vec<Line> = fields
1029            .iter()
1030            .map(|&(label, value)| crate::ui::labeled_field(label, value))
1031            .collect();
1032
1033        // At a narrow width (40 cols) all 8 fields should still have height > 0
1034        let narrow_height = calculate_dynamic_height(&lines, 40);
1035        assert!(
1036            narrow_height >= 8,
1037            "8 fields in 1 column should need at least 8 rows, got {narrow_height}"
1038        );
1039
1040        // At a wide width (240 cols) fewer rows are needed (fields spread across columns)
1041        let wide_height = calculate_dynamic_height(&lines, 240);
1042        assert!(
1043            wide_height < narrow_height,
1044            "Wide layout should be shorter than narrow: wide={wide_height} narrow={narrow_height}"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_notification_arn_format() {
1050        use crate::cfn::Stack;
1051
1052        // Test with notification ARNs
1053        let stack_with_notifications = Stack {
1054            name: "test-stack".to_string(),
1055            stack_id: "id".to_string(),
1056            status: "CREATE_COMPLETE".to_string(),
1057            created_time: String::new(),
1058            updated_time: String::new(),
1059            deleted_time: String::new(),
1060            drift_status: String::new(),
1061            last_drift_check_time: String::new(),
1062            status_reason: String::new(),
1063            description: String::new(),
1064            detailed_status: String::new(),
1065            root_stack: String::new(),
1066            parent_stack: String::new(),
1067            termination_protection: false,
1068            iam_role: String::new(),
1069            tags: Vec::new(),
1070            stack_policy: String::new(),
1071            rollback_monitoring_time: String::new(),
1072            rollback_alarms: Vec::new(),
1073            notification_arns: vec![
1074                "arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications".to_string(),
1075            ],
1076        };
1077
1078        let notification_lines: Vec<String> = stack_with_notifications
1079            .notification_arns
1080            .iter()
1081            .map(|arn| format!("SNS topic ARN: {}", arn))
1082            .collect();
1083
1084        assert_eq!(notification_lines.len(), 1);
1085        assert_eq!(
1086            notification_lines[0],
1087            "SNS topic ARN: arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications"
1088        );
1089
1090        // Test with no notifications
1091        let stack_without_notifications = Stack {
1092            name: "test-stack".to_string(),
1093            stack_id: "id".to_string(),
1094            status: "CREATE_COMPLETE".to_string(),
1095            created_time: String::new(),
1096            updated_time: String::new(),
1097            deleted_time: String::new(),
1098            drift_status: String::new(),
1099            last_drift_check_time: String::new(),
1100            status_reason: String::new(),
1101            description: String::new(),
1102            detailed_status: String::new(),
1103            root_stack: String::new(),
1104            parent_stack: String::new(),
1105            termination_protection: false,
1106            iam_role: String::new(),
1107            tags: Vec::new(),
1108            stack_policy: String::new(),
1109            rollback_monitoring_time: String::new(),
1110            rollback_alarms: Vec::new(),
1111            notification_arns: Vec::new(),
1112        };
1113
1114        let notification_lines: Vec<String> =
1115            if stack_without_notifications.notification_arns.is_empty() {
1116                vec!["SNS topic ARN: No notifications configured".to_string()]
1117            } else {
1118                stack_without_notifications
1119                    .notification_arns
1120                    .iter()
1121                    .map(|arn| format!("SNS topic ARN: {}", arn))
1122                    .collect()
1123            };
1124
1125        assert_eq!(notification_lines.len(), 1);
1126        assert_eq!(
1127            notification_lines[0],
1128            "SNS topic ARN: No notifications configured"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_template_scroll_state() {
1134        let state = State::new();
1135        assert_eq!(state.template_body, "");
1136        assert_eq!(state.template_scroll, 0);
1137    }
1138
1139    #[test]
1140    fn test_template_body_storage() {
1141        let mut state = State::new();
1142        let template = r#"{"AWSTemplateFormatVersion":"2010-09-09","Resources":{}}"#;
1143        state.template_body = template.to_string();
1144        assert_eq!(state.template_body, template);
1145    }
1146
1147    #[test]
1148    fn test_rollback_alarm_format() {
1149        let alarm_arn = "arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm";
1150        let formatted = format!("CloudWatch alarm ARN: {}", alarm_arn);
1151        assert_eq!(
1152            formatted,
1153            "CloudWatch alarm ARN: arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_filtered_tags() {
1159        let mut app = test_app();
1160        app.cfn_state.tags.items = vec![
1161            ("Environment".to_string(), "Production".to_string()),
1162            ("Application".to_string(), "WebApp".to_string()),
1163            ("Owner".to_string(), "TeamA".to_string()),
1164        ];
1165
1166        // No filter
1167        app.cfn_state.tags.filter = String::new();
1168        let filtered = filtered_tags(&app);
1169        assert_eq!(filtered.len(), 3);
1170
1171        // Filter by key
1172        app.cfn_state.tags.filter = "env".to_string();
1173        let filtered = filtered_tags(&app);
1174        assert_eq!(filtered.len(), 1);
1175        assert_eq!(filtered[0].0, "Environment");
1176
1177        // Filter by value
1178        app.cfn_state.tags.filter = "prod".to_string();
1179        let filtered = filtered_tags(&app);
1180        assert_eq!(filtered.len(), 1);
1181        assert_eq!(filtered[0].1, "Production");
1182    }
1183
1184    #[test]
1185    fn test_tags_sorted_by_key() {
1186        let mut tags = [
1187            ("Zebra".to_string(), "value1".to_string()),
1188            ("Alpha".to_string(), "value2".to_string()),
1189            ("Beta".to_string(), "value3".to_string()),
1190        ];
1191        tags.sort_by(|a, b| a.0.cmp(&b.0));
1192        assert_eq!(tags[0].0, "Alpha");
1193        assert_eq!(tags[1].0, "Beta");
1194        assert_eq!(tags[2].0, "Zebra");
1195    }
1196
1197    #[test]
1198    fn test_policy_scroll_state() {
1199        let state = State::new();
1200        assert_eq!(state.policy_scroll, 0);
1201    }
1202
1203    #[test]
1204    fn test_rounded_block_for_rollback_config() {
1205        use crate::ui::titled_block;
1206        use ratatui::prelude::Rect;
1207        let block = titled_block("Rollback configuration");
1208        let area = Rect::new(0, 0, 90, 8);
1209        let inner = block.inner(area);
1210        assert_eq!(inner.width, 88);
1211        assert_eq!(inner.height, 6);
1212    }
1213
1214    #[test]
1215    fn test_overview_uses_dynamic_height() {
1216        use crate::ui::{calculate_dynamic_height, labeled_field};
1217        // Verify overview height accounts for column packing
1218        let fields = vec![
1219            labeled_field("Stack name", "test-stack"),
1220            labeled_field("Status", "CREATE_COMPLETE"),
1221            labeled_field("Created", "2024-01-01"),
1222        ];
1223        let width = 150;
1224        let height = calculate_dynamic_height(&fields, width);
1225        // With 3 fields and reasonable width, should pack into fewer rows
1226        assert!(height < 3, "Expected fewer than 3 rows with column packing");
1227    }
1228
1229    #[test]
1230    fn test_resource_status_uses_format_status_emoji_and_color() {
1231        use super::ResourceColumn;
1232        use crate::ui::table::Column;
1233        use ratatui::style::Color;
1234        use rusticity_core::cfn::StackResource;
1235
1236        let col = ResourceColumn::Status;
1237
1238        // CREATE_COMPLETE → green ✅
1239        let res = StackResource {
1240            logical_id: "MyBucket".into(),
1241            physical_id: "my-bucket".into(),
1242            resource_type: "AWS::S3::Bucket".into(),
1243            status: "CREATE_COMPLETE".into(),
1244            module_info: String::new(),
1245        };
1246        let (text, style) = col.render(&res);
1247        assert!(
1248            text.contains("✅"),
1249            "CREATE_COMPLETE should have ✅ emoji, got: {text}"
1250        );
1251        assert_eq!(
1252            style.fg,
1253            Some(Color::Green),
1254            "CREATE_COMPLETE should be green"
1255        );
1256
1257        // CREATE_FAILED → red ❌
1258        let res_failed = StackResource {
1259            status: "CREATE_FAILED".into(),
1260            ..res.clone()
1261        };
1262        let (text, style) = col.render(&res_failed);
1263        assert!(
1264            text.contains("❌"),
1265            "CREATE_FAILED should have ❌ emoji, got: {text}"
1266        );
1267        assert_eq!(style.fg, Some(Color::Red), "CREATE_FAILED should be red");
1268
1269        // CREATE_IN_PROGRESS → blue ℹ️
1270        let res_ip = StackResource {
1271            status: "CREATE_IN_PROGRESS".into(),
1272            ..res.clone()
1273        };
1274        let (text, style) = col.render(&res_ip);
1275        assert!(
1276            text.contains("ℹ️"),
1277            "CREATE_IN_PROGRESS should have ℹ️ emoji, got: {text}"
1278        );
1279        assert_eq!(
1280            style.fg,
1281            Some(Color::Blue),
1282            "CREATE_IN_PROGRESS should be blue"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_resource_status_column_width_fits_emoji_and_status() {
1288        use super::ResourceColumn;
1289        use crate::ui::table::Column;
1290
1291        let col = ResourceColumn::Status;
1292        // Minimum width must accommodate emoji + longest status like
1293        // "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS" (44 chars) + emoji (4)
1294        // but at minimum it should be >= 35 to avoid truncating common statuses
1295        assert!(
1296            col.width() >= 35,
1297            "Status column width {} is too narrow for emoji+status",
1298            col.width()
1299        );
1300    }
1301}
1302
1303pub fn render_parameters(frame: &mut Frame, app: &App, area: Rect) {
1304    let chunks = Layout::default()
1305        .direction(Direction::Vertical)
1306        .constraints([Constraint::Length(3), Constraint::Min(0)])
1307        .split(area);
1308
1309    let filtered: Vec<&StackParameter> = if app.cfn_state.parameters.filter.is_empty() {
1310        app.cfn_state.parameters.items.iter().collect()
1311    } else {
1312        app.cfn_state
1313            .parameters
1314            .items
1315            .iter()
1316            .filter(|p| {
1317                p.key
1318                    .to_lowercase()
1319                    .contains(&app.cfn_state.parameters.filter.to_lowercase())
1320                    || p.value
1321                        .to_lowercase()
1322                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
1323                    || p.resolved_value
1324                        .to_lowercase()
1325                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
1326            })
1327            .collect()
1328    };
1329
1330    let filtered_count = filtered.len();
1331    let page_size = app.cfn_state.parameters.page_size.value();
1332    let total_pages = filtered_count.div_ceil(page_size);
1333    let current_page = if filtered_count > 0
1334        && app.cfn_state.parameters.scroll_offset + page_size >= filtered_count
1335    {
1336        total_pages.saturating_sub(1)
1337    } else {
1338        app.cfn_state.parameters.scroll_offset / page_size
1339    };
1340    let pagination = render_pagination_text(current_page, total_pages);
1341
1342    render_filter_bar(
1343        frame,
1344        FilterConfig {
1345            filter_text: &app.cfn_state.parameters.filter,
1346            placeholder: "Search parameters",
1347            mode: app.mode,
1348            is_input_focused: app.cfn_state.parameters_input_focus == InputFocus::Filter,
1349            controls: vec![FilterControl {
1350                text: pagination,
1351                is_focused: app.cfn_state.parameters_input_focus == InputFocus::Pagination,
1352            }],
1353            area: chunks[0],
1354        },
1355    );
1356
1357    let page_start = app.cfn_state.parameters.scroll_offset;
1358    let page_end = (page_start + page_size).min(filtered_count);
1359    let page_params: Vec<_> = filtered[page_start..page_end].to_vec();
1360
1361    let columns: Vec<Box<dyn Column<StackParameter>>> = app
1362        .cfn_parameter_visible_column_ids
1363        .iter()
1364        .filter_map(|col_id| {
1365            ParameterColumn::from_id(col_id)
1366                .map(|col| Box::new(col) as Box<dyn Column<StackParameter>>)
1367        })
1368        .collect();
1369
1370    let expanded_index = app.cfn_state.parameters.expanded_item.and_then(|idx| {
1371        let scroll_offset = app.cfn_state.parameters.scroll_offset;
1372        if idx >= scroll_offset && idx < scroll_offset + page_size {
1373            Some(idx - scroll_offset)
1374        } else {
1375            None
1376        }
1377    });
1378
1379    let config = TableConfig {
1380        items: page_params,
1381        selected_index: app.cfn_state.parameters.selected % page_size,
1382        expanded_index,
1383        columns: &columns,
1384        sort_column: "Key",
1385        sort_direction: SortDirection::Asc,
1386        title: format_title(&format!("Parameters ({})", filtered_count)),
1387        area: chunks[1],
1388        get_expanded_content: Some(Box::new(|param: &StackParameter| {
1389            expanded_from_columns(&columns, param)
1390        })),
1391        is_active: app.mode != Mode::FilterInput,
1392    };
1393
1394    render_table(frame, config);
1395}
1396
1397pub fn render_outputs(frame: &mut Frame, app: &App, area: Rect) {
1398    let chunks = Layout::default()
1399        .direction(Direction::Vertical)
1400        .constraints([Constraint::Length(3), Constraint::Min(0)])
1401        .split(area);
1402
1403    let filtered: Vec<&StackOutput> = if app.cfn_state.outputs.filter.is_empty() {
1404        app.cfn_state.outputs.items.iter().collect()
1405    } else {
1406        app.cfn_state
1407            .outputs
1408            .items
1409            .iter()
1410            .filter(|o| {
1411                o.key
1412                    .to_lowercase()
1413                    .contains(&app.cfn_state.outputs.filter.to_lowercase())
1414                    || o.value
1415                        .to_lowercase()
1416                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1417                    || o.description
1418                        .to_lowercase()
1419                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1420                    || o.export_name
1421                        .to_lowercase()
1422                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1423            })
1424            .collect()
1425    };
1426
1427    let filtered_count = filtered.len();
1428    let page_size = app.cfn_state.outputs.page_size.value();
1429    let total_pages = filtered_count.div_ceil(page_size);
1430    let current_page = if filtered_count > 0
1431        && app.cfn_state.outputs.scroll_offset + page_size >= filtered_count
1432    {
1433        total_pages.saturating_sub(1)
1434    } else {
1435        app.cfn_state.outputs.scroll_offset / page_size
1436    };
1437    let pagination = render_pagination_text(current_page, total_pages);
1438
1439    render_filter_bar(
1440        frame,
1441        FilterConfig {
1442            filter_text: &app.cfn_state.outputs.filter,
1443            placeholder: "Search outputs",
1444            mode: app.mode,
1445            is_input_focused: app.cfn_state.outputs_input_focus == InputFocus::Filter,
1446            controls: vec![FilterControl {
1447                text: pagination,
1448                is_focused: app.cfn_state.outputs_input_focus == InputFocus::Pagination,
1449            }],
1450            area: chunks[0],
1451        },
1452    );
1453
1454    let page_start = app.cfn_state.outputs.scroll_offset;
1455    let page_end = (page_start + page_size).min(filtered_count);
1456    let page_outputs: Vec<_> = filtered[page_start..page_end].to_vec();
1457
1458    let columns: Vec<Box<dyn Column<StackOutput>>> = app
1459        .cfn_output_visible_column_ids
1460        .iter()
1461        .filter_map(|col_id| {
1462            OutputColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<StackOutput>>)
1463        })
1464        .collect();
1465
1466    let expanded_index = app.cfn_state.outputs.expanded_item.and_then(|idx| {
1467        let scroll_offset = app.cfn_state.outputs.scroll_offset;
1468        if idx >= scroll_offset && idx < scroll_offset + page_size {
1469            Some(idx - scroll_offset)
1470        } else {
1471            None
1472        }
1473    });
1474
1475    let config = TableConfig {
1476        items: page_outputs,
1477        selected_index: app.cfn_state.outputs.selected % page_size,
1478        expanded_index,
1479        columns: &columns,
1480        sort_column: "Key",
1481        sort_direction: SortDirection::Asc,
1482        title: format_title(&format!("Outputs ({})", filtered_count)),
1483        area: chunks[1],
1484        get_expanded_content: Some(Box::new(|output: &StackOutput| {
1485            expanded_from_columns(&columns, output)
1486        })),
1487        is_active: app.mode != Mode::FilterInput,
1488    };
1489
1490    render_table(frame, config);
1491}
1492
1493pub fn render_resources(frame: &mut Frame, app: &App, area: Rect) {
1494    let chunks = Layout::default()
1495        .direction(Direction::Vertical)
1496        .constraints([Constraint::Length(3), Constraint::Min(0)])
1497        .split(area);
1498
1499    let filtered: Vec<&StackResource> = filtered_resources(app);
1500    let filtered_count = filtered.len();
1501    let page_size = app.cfn_state.resources.page_size.value();
1502    let total_pages = filtered_count.div_ceil(page_size);
1503    let current_page = if filtered_count > 0
1504        && app.cfn_state.resources.scroll_offset + page_size >= filtered_count
1505    {
1506        total_pages.saturating_sub(1)
1507    } else {
1508        app.cfn_state.resources.scroll_offset / page_size
1509    };
1510    let pagination = render_pagination_text(current_page, total_pages);
1511
1512    render_filter_bar(
1513        frame,
1514        FilterConfig {
1515            filter_text: &app.cfn_state.resources.filter,
1516            placeholder: "Search resources",
1517            mode: app.mode,
1518            is_input_focused: app.cfn_state.resources_input_focus == InputFocus::Filter,
1519            controls: vec![FilterControl {
1520                text: pagination,
1521                is_focused: app.cfn_state.resources_input_focus == InputFocus::Pagination,
1522            }],
1523            area: chunks[0],
1524        },
1525    );
1526
1527    let page_start = app.cfn_state.resources.scroll_offset;
1528    let page_end = (page_start + page_size).min(filtered_count);
1529    let page_resources: Vec<_> = filtered[page_start..page_end].to_vec();
1530
1531    let columns: Vec<Box<dyn Column<StackResource>>> = app
1532        .cfn_resource_visible_column_ids
1533        .iter()
1534        .filter_map(|col_id| {
1535            ResourceColumn::from_id(col_id)
1536                .map(|col| Box::new(col) as Box<dyn Column<StackResource>>)
1537        })
1538        .collect();
1539
1540    let expanded_index = app.cfn_state.resources.expanded_item.and_then(|idx| {
1541        let scroll_offset = app.cfn_state.resources.scroll_offset;
1542        if idx >= scroll_offset && idx < scroll_offset + page_size {
1543            Some(idx - scroll_offset)
1544        } else {
1545            None
1546        }
1547    });
1548
1549    let config = TableConfig {
1550        items: page_resources,
1551        selected_index: app.cfn_state.resources.selected % page_size,
1552        expanded_index,
1553        columns: &columns,
1554        sort_column: "Logical ID",
1555        sort_direction: SortDirection::Asc,
1556        title: format_title(&format!("Resources ({})", filtered_count)),
1557        area: chunks[1],
1558        get_expanded_content: Some(Box::new(|resource: &StackResource| {
1559            expanded_from_columns(&columns, resource)
1560        })),
1561        is_active: app.mode != Mode::FilterInput,
1562    };
1563
1564    render_table(frame, config);
1565}
1566
1567pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
1568    render_events_table(frame, app, area);
1569}
1570
1571fn render_events_table(frame: &mut Frame, app: &App, area: Rect) {
1572    let chunks = Layout::default()
1573        .direction(Direction::Vertical)
1574        .constraints([
1575            Constraint::Length(3), // filter bar
1576            Constraint::Min(0),    // table
1577        ])
1578        .split(area);
1579
1580    let filtered = filtered_events(app);
1581    let filtered_count = filtered.len();
1582    let page_size = app.cfn_state.events.page_size.value();
1583    let total_pages = filtered_count.div_ceil(page_size);
1584    let current_page =
1585        if filtered_count > 0 && app.cfn_state.events.scroll_offset + page_size >= filtered_count {
1586            total_pages.saturating_sub(1)
1587        } else {
1588            app.cfn_state.events.scroll_offset / page_size
1589        };
1590    let pagination = render_pagination_text(current_page, total_pages);
1591
1592    render_filter_bar(
1593        frame,
1594        FilterConfig {
1595            filter_text: &app.cfn_state.events.filter,
1596            placeholder: "Search events",
1597            mode: app.mode,
1598            is_input_focused: app.cfn_state.events_input_focus == InputFocus::Filter,
1599            controls: vec![FilterControl {
1600                text: pagination,
1601                is_focused: app.cfn_state.events_input_focus == InputFocus::Pagination,
1602            }],
1603            area: chunks[0],
1604        },
1605    );
1606
1607    let page_start = app.cfn_state.events.scroll_offset;
1608    let page_end = (page_start + page_size).min(filtered_count);
1609    let page_events: Vec<_> = filtered[page_start..page_end].to_vec();
1610
1611    let columns: Vec<Box<dyn Column<StackEvent>>> = app
1612        .cfn_event_visible_column_ids
1613        .iter()
1614        .filter_map(|col_id| {
1615            EventColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<StackEvent>>)
1616        })
1617        .collect();
1618
1619    let expanded_index = app.cfn_state.events.expanded_item.and_then(|idx| {
1620        let scroll_offset = app.cfn_state.events.scroll_offset;
1621        if idx >= scroll_offset && idx < scroll_offset + page_size {
1622            Some(idx - scroll_offset)
1623        } else {
1624            None
1625        }
1626    });
1627
1628    let config = TableConfig {
1629        items: page_events,
1630        selected_index: app.cfn_state.events.selected % page_size,
1631        expanded_index,
1632        columns: &columns,
1633        sort_column: "Timestamp",
1634        sort_direction: SortDirection::Desc,
1635        title: format_title(&format!("Events ({})", filtered_count)),
1636        area: chunks[1],
1637        get_expanded_content: Some(Box::new(|event: &StackEvent| {
1638            expanded_from_columns(&columns, event)
1639        })),
1640        is_active: app.mode != Mode::FilterInput,
1641    };
1642
1643    render_table(frame, config);
1644}
1645
1646pub fn render_change_sets(frame: &mut Frame, app: &App, area: Rect) {
1647    let chunks = Layout::default()
1648        .direction(Direction::Vertical)
1649        .constraints([Constraint::Length(3), Constraint::Min(0)])
1650        .split(area);
1651
1652    let filtered = filtered_change_sets(app);
1653    let filtered_count = filtered.len();
1654    let page_size = app.cfn_state.change_sets.page_size.value();
1655    let total_pages = filtered_count.div_ceil(page_size);
1656    let current_page = if filtered_count > 0
1657        && app.cfn_state.change_sets.scroll_offset + page_size >= filtered_count
1658    {
1659        total_pages.saturating_sub(1)
1660    } else {
1661        app.cfn_state.change_sets.scroll_offset / page_size
1662    };
1663    let pagination = render_pagination_text(current_page, total_pages);
1664
1665    render_filter_bar(
1666        frame,
1667        FilterConfig {
1668            filter_text: &app.cfn_state.change_sets.filter,
1669            placeholder: "Search change sets",
1670            mode: app.mode,
1671            is_input_focused: app.cfn_state.change_sets_input_focus == InputFocus::Filter,
1672            controls: vec![FilterControl {
1673                text: pagination,
1674                is_focused: app.cfn_state.change_sets_input_focus == InputFocus::Pagination,
1675            }],
1676            area: chunks[0],
1677        },
1678    );
1679
1680    let page_start = app.cfn_state.change_sets.scroll_offset;
1681    let page_end = (page_start + page_size).min(filtered_count);
1682    let page_items: Vec<_> = filtered[page_start..page_end].to_vec();
1683
1684    let columns: Vec<Box<dyn Column<StackChangeSet>>> = app
1685        .cfn_change_set_visible_column_ids
1686        .iter()
1687        .filter_map(|col_id| {
1688            ChangeSetColumn::from_id(col_id)
1689                .map(|col| Box::new(col) as Box<dyn Column<StackChangeSet>>)
1690        })
1691        .collect();
1692
1693    let expanded_index = app.cfn_state.change_sets.expanded_item.and_then(|idx| {
1694        let scroll_offset = app.cfn_state.change_sets.scroll_offset;
1695        if idx >= scroll_offset && idx < scroll_offset + page_size {
1696            Some(idx - scroll_offset)
1697        } else {
1698            None
1699        }
1700    });
1701
1702    let config = TableConfig {
1703        items: page_items,
1704        selected_index: app.cfn_state.change_sets.selected % page_size,
1705        expanded_index,
1706        columns: &columns,
1707        sort_column: "Created time",
1708        sort_direction: SortDirection::Desc,
1709        title: format_title(&format!("Change sets ({})", filtered_count)),
1710        area: chunks[1],
1711        get_expanded_content: Some(Box::new(|cs: &StackChangeSet| {
1712            expanded_from_columns(&columns, cs)
1713        })),
1714        is_active: app.mode != Mode::FilterInput,
1715    };
1716
1717    render_table(frame, config);
1718}
1719
1720#[derive(Debug, Clone, Copy, PartialEq)]
1721pub enum ParameterColumn {
1722    Key,
1723    Value,
1724    ResolvedValue,
1725}
1726
1727impl ParameterColumn {
1728    fn id(&self) -> &'static str {
1729        match self {
1730            ParameterColumn::Key => "cfn.parameter.key",
1731            ParameterColumn::Value => "cfn.parameter.value",
1732            ParameterColumn::ResolvedValue => "cfn.parameter.resolved_value",
1733        }
1734    }
1735
1736    pub fn default_name(&self) -> &'static str {
1737        match self {
1738            ParameterColumn::Key => "Key",
1739            ParameterColumn::Value => "Value",
1740            ParameterColumn::ResolvedValue => "Resolved value",
1741        }
1742    }
1743
1744    pub fn all() -> Vec<Self> {
1745        vec![Self::Key, Self::Value, Self::ResolvedValue]
1746    }
1747
1748    pub fn from_id(id: &str) -> Option<Self> {
1749        match id {
1750            "cfn.parameter.key" => Some(Self::Key),
1751            "cfn.parameter.value" => Some(Self::Value),
1752            "cfn.parameter.resolved_value" => Some(Self::ResolvedValue),
1753            _ => None,
1754        }
1755    }
1756}
1757
1758impl Column<StackParameter> for ParameterColumn {
1759    fn id(&self) -> &'static str {
1760        Self::id(self)
1761    }
1762
1763    fn default_name(&self) -> &'static str {
1764        Self::default_name(self)
1765    }
1766
1767    fn width(&self) -> u16 {
1768        let translated = translate_column(self.id(), self.default_name());
1769        translated.len().max(match self {
1770            ParameterColumn::Key => 40,
1771            ParameterColumn::Value => 50,
1772            ParameterColumn::ResolvedValue => 50,
1773        }) as u16
1774    }
1775
1776    fn render(&self, item: &StackParameter) -> (String, Style) {
1777        match self {
1778            ParameterColumn::Key => (item.key.clone(), Style::default()),
1779            ParameterColumn::Value => (item.value.clone(), Style::default()),
1780            ParameterColumn::ResolvedValue => (item.resolved_value.clone(), Style::default()),
1781        }
1782    }
1783}
1784
1785#[derive(Debug, Clone, Copy, PartialEq)]
1786pub enum OutputColumn {
1787    Key,
1788    Value,
1789    Description,
1790    ExportName,
1791}
1792
1793impl OutputColumn {
1794    fn id(&self) -> &'static str {
1795        match self {
1796            OutputColumn::Key => "cfn.output.key",
1797            OutputColumn::Value => "cfn.output.value",
1798            OutputColumn::Description => "cfn.output.description",
1799            OutputColumn::ExportName => "cfn.output.export_name",
1800        }
1801    }
1802
1803    pub fn default_name(&self) -> &'static str {
1804        match self {
1805            OutputColumn::Key => "Key",
1806            OutputColumn::Value => "Value",
1807            OutputColumn::Description => "Description",
1808            OutputColumn::ExportName => "Export name",
1809        }
1810    }
1811
1812    pub fn all() -> Vec<Self> {
1813        vec![Self::Key, Self::Value, Self::Description, Self::ExportName]
1814    }
1815
1816    pub fn from_id(id: &str) -> Option<Self> {
1817        match id {
1818            "cfn.output.key" => Some(Self::Key),
1819            "cfn.output.value" => Some(Self::Value),
1820            "cfn.output.description" => Some(Self::Description),
1821            "cfn.output.export_name" => Some(Self::ExportName),
1822            _ => None,
1823        }
1824    }
1825}
1826
1827impl Column<StackOutput> for OutputColumn {
1828    fn id(&self) -> &'static str {
1829        Self::id(self)
1830    }
1831
1832    fn default_name(&self) -> &'static str {
1833        Self::default_name(self)
1834    }
1835
1836    fn width(&self) -> u16 {
1837        let translated = translate_column(self.id(), self.default_name());
1838        translated.len().max(match self {
1839            OutputColumn::Key => 40,
1840            OutputColumn::Value => 50,
1841            OutputColumn::Description => 50,
1842            OutputColumn::ExportName => 40,
1843        }) as u16
1844    }
1845
1846    fn render(&self, item: &StackOutput) -> (String, Style) {
1847        match self {
1848            OutputColumn::Key => (item.key.clone(), Style::default()),
1849            OutputColumn::Value => (item.value.clone(), Style::default()),
1850            OutputColumn::Description => (item.description.clone(), Style::default()),
1851            OutputColumn::ExportName => (item.export_name.clone(), Style::default()),
1852        }
1853    }
1854}
1855
1856impl Column<StackResource> for ResourceColumn {
1857    fn id(&self) -> &'static str {
1858        Self::id(self)
1859    }
1860
1861    fn default_name(&self) -> &'static str {
1862        Self::default_name(self)
1863    }
1864
1865    fn width(&self) -> u16 {
1866        let translated = translate_column(self.id(), self.default_name());
1867        translated.len().max(match self {
1868            ResourceColumn::LogicalId => 40,
1869            ResourceColumn::PhysicalId => 50,
1870            ResourceColumn::Type => 40,
1871            ResourceColumn::Status => 35,
1872            ResourceColumn::Module => 40,
1873        }) as u16
1874    }
1875
1876    fn render(&self, item: &StackResource) -> (String, Style) {
1877        match self {
1878            ResourceColumn::LogicalId => (item.logical_id.clone(), Style::default()),
1879            ResourceColumn::PhysicalId => (item.physical_id.clone(), Style::default()),
1880            ResourceColumn::Type => (item.resource_type.clone(), Style::default()),
1881            ResourceColumn::Status => {
1882                let (formatted, color) = format_status(&item.status);
1883                (formatted, Style::default().fg(color))
1884            }
1885            ResourceColumn::Module => (item.module_info.clone(), Style::default()),
1886        }
1887    }
1888}
1889
1890// ── Event column ──────────────────────────────────────────────────────────────
1891
1892#[derive(Debug, Clone, Copy, PartialEq)]
1893pub enum EventColumn {
1894    OperationId,
1895    Timestamp,
1896    LogicalId,
1897    Status,
1898    DetailedStatus,
1899    StatusReason,
1900    HookInvocationCount,
1901    Type,
1902    PhysicalId,
1903    ClientRequestToken,
1904}
1905
1906impl EventColumn {
1907    const ID_OPERATION_ID: &'static str = "column.cfn.event.operation_id";
1908    const ID_TIMESTAMP: &'static str = "column.cfn.event.timestamp";
1909    const ID_LOGICAL_ID: &'static str = "column.cfn.event.logical_id";
1910    const ID_STATUS: &'static str = "column.cfn.event.status";
1911    const ID_DETAILED_STATUS: &'static str = "column.cfn.event.detailed_status";
1912    const ID_STATUS_REASON: &'static str = "column.cfn.event.status_reason";
1913    const ID_HOOK_INVOCATION_COUNT: &'static str = "column.cfn.event.hook_invocation_count";
1914    const ID_TYPE: &'static str = "column.cfn.event.type";
1915    const ID_PHYSICAL_ID: &'static str = "column.cfn.event.physical_id";
1916    const ID_CLIENT_REQUEST_TOKEN: &'static str = "column.cfn.event.client_request_token";
1917
1918    pub const fn id(&self) -> &'static str {
1919        match self {
1920            EventColumn::OperationId => Self::ID_OPERATION_ID,
1921            EventColumn::Timestamp => Self::ID_TIMESTAMP,
1922            EventColumn::LogicalId => Self::ID_LOGICAL_ID,
1923            EventColumn::Status => Self::ID_STATUS,
1924            EventColumn::DetailedStatus => Self::ID_DETAILED_STATUS,
1925            EventColumn::StatusReason => Self::ID_STATUS_REASON,
1926            EventColumn::HookInvocationCount => Self::ID_HOOK_INVOCATION_COUNT,
1927            EventColumn::Type => Self::ID_TYPE,
1928            EventColumn::PhysicalId => Self::ID_PHYSICAL_ID,
1929            EventColumn::ClientRequestToken => Self::ID_CLIENT_REQUEST_TOKEN,
1930        }
1931    }
1932
1933    pub const fn default_name(&self) -> &'static str {
1934        match self {
1935            EventColumn::OperationId => "Operation ID",
1936            EventColumn::Timestamp => "Timestamp",
1937            EventColumn::LogicalId => "Logical ID",
1938            EventColumn::Status => "Status",
1939            EventColumn::DetailedStatus => "Detailed status",
1940            EventColumn::StatusReason => "Status reason",
1941            EventColumn::HookInvocationCount => "Hook invocations",
1942            EventColumn::Type => "Type",
1943            EventColumn::PhysicalId => "Physical ID",
1944            EventColumn::ClientRequestToken => "Client request token",
1945        }
1946    }
1947
1948    pub fn all() -> Vec<EventColumn> {
1949        vec![
1950            EventColumn::OperationId,
1951            EventColumn::Timestamp,
1952            EventColumn::LogicalId,
1953            EventColumn::Status,
1954            EventColumn::DetailedStatus,
1955            EventColumn::StatusReason,
1956            EventColumn::HookInvocationCount,
1957            EventColumn::Type,
1958            EventColumn::PhysicalId,
1959            EventColumn::ClientRequestToken,
1960        ]
1961    }
1962
1963    pub fn from_id(id: &str) -> Option<EventColumn> {
1964        match id {
1965            Self::ID_OPERATION_ID => Some(EventColumn::OperationId),
1966            Self::ID_TIMESTAMP => Some(EventColumn::Timestamp),
1967            Self::ID_LOGICAL_ID => Some(EventColumn::LogicalId),
1968            Self::ID_STATUS => Some(EventColumn::Status),
1969            Self::ID_DETAILED_STATUS => Some(EventColumn::DetailedStatus),
1970            Self::ID_STATUS_REASON => Some(EventColumn::StatusReason),
1971            Self::ID_HOOK_INVOCATION_COUNT => Some(EventColumn::HookInvocationCount),
1972            Self::ID_TYPE => Some(EventColumn::Type),
1973            Self::ID_PHYSICAL_ID => Some(EventColumn::PhysicalId),
1974            Self::ID_CLIENT_REQUEST_TOKEN => Some(EventColumn::ClientRequestToken),
1975            _ => None,
1976        }
1977    }
1978}
1979
1980impl Column<StackEvent> for EventColumn {
1981    fn id(&self) -> &'static str {
1982        Self::id(self)
1983    }
1984
1985    fn default_name(&self) -> &'static str {
1986        Self::default_name(self)
1987    }
1988
1989    fn width(&self) -> u16 {
1990        let translated = translate_column(self.id(), self.default_name());
1991        translated.len().max(match self {
1992            EventColumn::OperationId => 20,
1993            EventColumn::Timestamp => UTC_TIMESTAMP_WIDTH as usize,
1994            EventColumn::LogicalId => 40,
1995            EventColumn::Status => 35,
1996            EventColumn::DetailedStatus => 30,
1997            EventColumn::StatusReason => 50,
1998            EventColumn::HookInvocationCount => 18,
1999            EventColumn::Type => 40,
2000            EventColumn::PhysicalId => 50,
2001            EventColumn::ClientRequestToken => 30,
2002        }) as u16
2003    }
2004
2005    fn render(&self, item: &StackEvent) -> (String, Style) {
2006        match self {
2007            EventColumn::OperationId => (item.event_id.clone(), Style::default()),
2008            EventColumn::Timestamp => (format_iso_timestamp(&item.timestamp), Style::default()),
2009            EventColumn::LogicalId => (item.logical_id.clone(), Style::default()),
2010            EventColumn::Status => {
2011                let (formatted, color) = format_status(&item.status);
2012                (formatted, Style::default().fg(color))
2013            }
2014            EventColumn::DetailedStatus => (item.detailed_status.clone(), Style::default()),
2015            EventColumn::StatusReason => (item.status_reason.clone(), Style::default()),
2016            EventColumn::HookInvocationCount => {
2017                (item.hook_invocation_count.clone(), Style::default())
2018            }
2019            EventColumn::Type => (item.resource_type.clone(), Style::default()),
2020            EventColumn::PhysicalId => (item.physical_id.clone(), Style::default()),
2021            EventColumn::ClientRequestToken => {
2022                (item.client_request_token.clone(), Style::default())
2023            }
2024        }
2025    }
2026}
2027
2028// ── Change Set column ─────────────────────────────────────────────────────────
2029
2030#[derive(Debug, Clone, Copy, PartialEq)]
2031pub enum ChangeSetColumn {
2032    Name,
2033    CreatedTime,
2034    Status,
2035    Description,
2036    RootChangeSetId,
2037    ParentChangeSetId,
2038}
2039
2040impl ChangeSetColumn {
2041    const ID_NAME: &'static str = "column.cfn.changeset.name";
2042    const ID_CREATED_TIME: &'static str = "column.cfn.changeset.created_time";
2043    const ID_STATUS: &'static str = "column.cfn.changeset.status";
2044    const ID_DESCRIPTION: &'static str = "column.cfn.changeset.description";
2045    const ID_ROOT_CHANGE_SET_ID: &'static str = "column.cfn.changeset.root_change_set_id";
2046    const ID_PARENT_CHANGE_SET_ID: &'static str = "column.cfn.changeset.parent_change_set_id";
2047
2048    pub const fn id(&self) -> &'static str {
2049        match self {
2050            ChangeSetColumn::Name => Self::ID_NAME,
2051            ChangeSetColumn::CreatedTime => Self::ID_CREATED_TIME,
2052            ChangeSetColumn::Status => Self::ID_STATUS,
2053            ChangeSetColumn::Description => Self::ID_DESCRIPTION,
2054            ChangeSetColumn::RootChangeSetId => Self::ID_ROOT_CHANGE_SET_ID,
2055            ChangeSetColumn::ParentChangeSetId => Self::ID_PARENT_CHANGE_SET_ID,
2056        }
2057    }
2058
2059    pub const fn default_name(&self) -> &'static str {
2060        match self {
2061            ChangeSetColumn::Name => "Name",
2062            ChangeSetColumn::CreatedTime => "Created time",
2063            ChangeSetColumn::Status => "Status",
2064            ChangeSetColumn::Description => "Description",
2065            ChangeSetColumn::RootChangeSetId => "Root change set ID",
2066            ChangeSetColumn::ParentChangeSetId => "Parent change set ID",
2067        }
2068    }
2069
2070    pub fn all() -> Vec<ChangeSetColumn> {
2071        vec![
2072            ChangeSetColumn::Name,
2073            ChangeSetColumn::CreatedTime,
2074            ChangeSetColumn::Status,
2075            ChangeSetColumn::Description,
2076            ChangeSetColumn::RootChangeSetId,
2077            ChangeSetColumn::ParentChangeSetId,
2078        ]
2079    }
2080
2081    pub fn from_id(id: &str) -> Option<ChangeSetColumn> {
2082        match id {
2083            Self::ID_NAME => Some(ChangeSetColumn::Name),
2084            Self::ID_CREATED_TIME => Some(ChangeSetColumn::CreatedTime),
2085            Self::ID_STATUS => Some(ChangeSetColumn::Status),
2086            Self::ID_DESCRIPTION => Some(ChangeSetColumn::Description),
2087            Self::ID_ROOT_CHANGE_SET_ID => Some(ChangeSetColumn::RootChangeSetId),
2088            Self::ID_PARENT_CHANGE_SET_ID => Some(ChangeSetColumn::ParentChangeSetId),
2089            _ => None,
2090        }
2091    }
2092}
2093
2094impl Column<StackChangeSet> for ChangeSetColumn {
2095    fn id(&self) -> &'static str {
2096        Self::id(self)
2097    }
2098
2099    fn default_name(&self) -> &'static str {
2100        Self::default_name(self)
2101    }
2102
2103    fn width(&self) -> u16 {
2104        let translated = translate_column(self.id(), self.default_name());
2105        translated.len().max(match self {
2106            ChangeSetColumn::Name => 40,
2107            ChangeSetColumn::CreatedTime => UTC_TIMESTAMP_WIDTH as usize,
2108            ChangeSetColumn::Status => 30,
2109            ChangeSetColumn::Description => 50,
2110            ChangeSetColumn::RootChangeSetId => 50,
2111            ChangeSetColumn::ParentChangeSetId => 50,
2112        }) as u16
2113    }
2114
2115    fn render(&self, item: &StackChangeSet) -> (String, Style) {
2116        match self {
2117            ChangeSetColumn::Name => (item.name.clone(), Style::default()),
2118            ChangeSetColumn::CreatedTime => {
2119                (format_iso_timestamp(&item.created_time), Style::default())
2120            }
2121            ChangeSetColumn::Status => {
2122                let (formatted, color) = format_status(&item.status);
2123                (formatted, Style::default().fg(color))
2124            }
2125            ChangeSetColumn::Description => (item.description.clone(), Style::default()),
2126            ChangeSetColumn::RootChangeSetId => (item.root_change_set_id.clone(), Style::default()),
2127            ChangeSetColumn::ParentChangeSetId => {
2128                (item.parent_change_set_id.clone(), Style::default())
2129            }
2130        }
2131    }
2132}
2133
2134#[derive(Debug, Clone, Copy, PartialEq)]
2135enum TagColumn {
2136    Key,
2137    Value,
2138}
2139
2140impl Column<(String, String)> for TagColumn {
2141    fn name(&self) -> &str {
2142        match self {
2143            TagColumn::Key => "Key",
2144            TagColumn::Value => "Value",
2145        }
2146    }
2147
2148    fn width(&self) -> u16 {
2149        match self {
2150            TagColumn::Key => 40,
2151            TagColumn::Value => 60,
2152        }
2153    }
2154
2155    fn render(&self, item: &(String, String)) -> (String, Style) {
2156        match self {
2157            TagColumn::Key => (item.0.clone(), Style::default()),
2158            TagColumn::Value => (item.1.clone(), Style::default()),
2159        }
2160    }
2161}
2162
2163#[cfg(test)]
2164mod parameter_tests {
2165    use super::*;
2166    use crate::app::App;
2167
2168    fn test_app() -> App {
2169        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
2170    }
2171
2172    #[test]
2173    fn test_filtered_parameters_empty_filter() {
2174        let mut app = test_app();
2175        app.cfn_state.parameters.items = vec![
2176            StackParameter {
2177                key: "Param1".to_string(),
2178                value: "Value1".to_string(),
2179                resolved_value: "Resolved1".to_string(),
2180            },
2181            StackParameter {
2182                key: "Param2".to_string(),
2183                value: "Value2".to_string(),
2184                resolved_value: "Resolved2".to_string(),
2185            },
2186        ];
2187        app.cfn_state.parameters.filter = String::new();
2188
2189        let filtered = filtered_parameters(&app);
2190        assert_eq!(filtered.len(), 2);
2191    }
2192
2193    #[test]
2194    fn test_filtered_parameters_by_key() {
2195        let mut app = test_app();
2196        app.cfn_state.parameters.items = vec![
2197            StackParameter {
2198                key: "DatabaseName".to_string(),
2199                value: "mydb".to_string(),
2200                resolved_value: "mydb".to_string(),
2201            },
2202            StackParameter {
2203                key: "InstanceType".to_string(),
2204                value: "t2.micro".to_string(),
2205                resolved_value: "t2.micro".to_string(),
2206            },
2207        ];
2208        app.cfn_state.parameters.filter = "database".to_string();
2209
2210        let filtered = filtered_parameters(&app);
2211        assert_eq!(filtered.len(), 1);
2212        assert_eq!(filtered[0].key, "DatabaseName");
2213    }
2214
2215    #[test]
2216    fn test_filtered_parameters_by_value() {
2217        let mut app = test_app();
2218        app.cfn_state.parameters.items = vec![
2219            StackParameter {
2220                key: "Param1".to_string(),
2221                value: "production".to_string(),
2222                resolved_value: "production".to_string(),
2223            },
2224            StackParameter {
2225                key: "Param2".to_string(),
2226                value: "staging".to_string(),
2227                resolved_value: "staging".to_string(),
2228            },
2229        ];
2230        app.cfn_state.parameters.filter = "prod".to_string();
2231
2232        let filtered = filtered_parameters(&app);
2233        assert_eq!(filtered.len(), 1);
2234        assert_eq!(filtered[0].value, "production");
2235    }
2236
2237    #[test]
2238    fn test_parameters_state_initialization() {
2239        let state = State::new();
2240        assert_eq!(state.parameters.items.len(), 0);
2241        assert_eq!(state.parameters.selected, 0);
2242        assert_eq!(state.parameters.filter, "");
2243        assert_eq!(state.parameters_input_focus, InputFocus::Filter);
2244    }
2245
2246    #[test]
2247    fn test_parameters_expansion() {
2248        use crate::app::Service;
2249        let mut app = test_app();
2250        app.current_service = Service::CloudFormationStacks;
2251        app.cfn_state.current_stack = Some("test-stack".to_string());
2252        app.cfn_state.detail_tab = DetailTab::Parameters;
2253        app.cfn_state.parameters.items = vec![StackParameter {
2254            key: "Param1".to_string(),
2255            value: "Value1".to_string(),
2256            resolved_value: "Resolved1".to_string(),
2257        }];
2258
2259        assert_eq!(app.cfn_state.parameters.expanded_item, None);
2260
2261        // Expand
2262        app.cfn_state.parameters.toggle_expand();
2263        assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
2264
2265        // Collapse
2266        app.cfn_state.parameters.collapse();
2267        assert_eq!(app.cfn_state.parameters.expanded_item, None);
2268    }
2269}
2270
2271#[cfg(test)]
2272mod output_tests {
2273    use super::*;
2274    use crate::app::App;
2275
2276    fn test_app() -> App {
2277        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
2278    }
2279
2280    #[test]
2281    fn test_filtered_outputs_empty_filter() {
2282        let mut app = test_app();
2283        app.cfn_state.outputs.items = vec![
2284            StackOutput {
2285                key: "Output1".to_string(),
2286                value: "Value1".to_string(),
2287                description: "Desc1".to_string(),
2288                export_name: "Export1".to_string(),
2289            },
2290            StackOutput {
2291                key: "Output2".to_string(),
2292                value: "Value2".to_string(),
2293                description: "Desc2".to_string(),
2294                export_name: "Export2".to_string(),
2295            },
2296        ];
2297        app.cfn_state.outputs.filter = String::new();
2298
2299        let filtered = filtered_outputs(&app);
2300        assert_eq!(filtered.len(), 2);
2301    }
2302
2303    #[test]
2304    fn test_filtered_outputs_by_key() {
2305        let mut app = test_app();
2306        app.cfn_state.outputs.items = vec![
2307            StackOutput {
2308                key: "ApiUrl".to_string(),
2309                value: "https://api.example.com".to_string(),
2310                description: "API endpoint".to_string(),
2311                export_name: "MyApiUrl".to_string(),
2312            },
2313            StackOutput {
2314                key: "BucketName".to_string(),
2315                value: "my-bucket".to_string(),
2316                description: "S3 bucket".to_string(),
2317                export_name: "MyBucket".to_string(),
2318            },
2319        ];
2320        app.cfn_state.outputs.filter = "api".to_string();
2321
2322        let filtered = filtered_outputs(&app);
2323        assert_eq!(filtered.len(), 1);
2324        assert_eq!(filtered[0].key, "ApiUrl");
2325    }
2326
2327    #[test]
2328    fn test_filtered_outputs_by_value() {
2329        let mut app = test_app();
2330        app.cfn_state.outputs.items = vec![
2331            StackOutput {
2332                key: "ApiUrl".to_string(),
2333                value: "https://api.example.com".to_string(),
2334                description: "API endpoint".to_string(),
2335                export_name: "MyApiUrl".to_string(),
2336            },
2337            StackOutput {
2338                key: "BucketName".to_string(),
2339                value: "my-bucket".to_string(),
2340                description: "S3 bucket".to_string(),
2341                export_name: "MyBucket".to_string(),
2342            },
2343        ];
2344        app.cfn_state.outputs.filter = "my-bucket".to_string();
2345
2346        let filtered = filtered_outputs(&app);
2347        assert_eq!(filtered.len(), 1);
2348        assert_eq!(filtered[0].key, "BucketName");
2349    }
2350
2351    #[test]
2352    fn test_outputs_state_initialization() {
2353        let app = test_app();
2354        assert_eq!(app.cfn_state.outputs.items.len(), 0);
2355        assert_eq!(app.cfn_state.outputs.filter, "");
2356        assert_eq!(app.cfn_state.outputs.selected, 0);
2357        assert_eq!(app.cfn_state.outputs.expanded_item, None);
2358    }
2359
2360    #[test]
2361    fn test_outputs_expansion() {
2362        let mut app = test_app();
2363        app.cfn_state.current_stack = Some("test-stack".to_string());
2364        app.cfn_state.detail_tab = DetailTab::Outputs;
2365        app.cfn_state.outputs.items = vec![StackOutput {
2366            key: "Output1".to_string(),
2367            value: "Value1".to_string(),
2368            description: "Desc1".to_string(),
2369            export_name: "Export1".to_string(),
2370        }];
2371
2372        assert_eq!(app.cfn_state.outputs.expanded_item, None);
2373
2374        // Expand
2375        app.cfn_state.outputs.toggle_expand();
2376        assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
2377
2378        // Collapse
2379        app.cfn_state.outputs.collapse();
2380        assert_eq!(app.cfn_state.outputs.expanded_item, None);
2381    }
2382
2383    #[test]
2384    fn test_expanded_items_hierarchical_view() {
2385        let mut app = test_app();
2386
2387        // Initially empty
2388        assert!(app.cfn_state.expanded_items.is_empty());
2389
2390        // Expand a resource
2391        app.cfn_state
2392            .expanded_items
2393            .insert("MyNestedStack".to_string());
2394        assert!(app.cfn_state.expanded_items.contains("MyNestedStack"));
2395
2396        // Expand another resource
2397        app.cfn_state
2398            .expanded_items
2399            .insert("MyNestedStack/ChildResource".to_string());
2400        assert_eq!(app.cfn_state.expanded_items.len(), 2);
2401
2402        // Collapse a resource
2403        app.cfn_state.expanded_items.remove("MyNestedStack");
2404        assert!(!app.cfn_state.expanded_items.contains("MyNestedStack"));
2405        assert_eq!(app.cfn_state.expanded_items.len(), 1);
2406    }
2407}
2408
2409#[cfg(test)]
2410mod resource_tests {
2411    use super::*;
2412    use crate::app::App;
2413
2414    fn test_app() -> App {
2415        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
2416    }
2417
2418    #[test]
2419    fn test_resources_state_initialization() {
2420        let app = test_app();
2421        assert_eq!(app.cfn_state.resources.items.len(), 0);
2422        assert_eq!(app.cfn_state.resources.filter, "");
2423        assert_eq!(app.cfn_state.resources.selected, 0);
2424    }
2425
2426    #[test]
2427    fn test_filtered_resources_empty_filter() {
2428        let mut app = test_app();
2429        app.cfn_state.resources.items = vec![
2430            StackResource {
2431                logical_id: "MyBucket".to_string(),
2432                physical_id: "my-bucket-123".to_string(),
2433                resource_type: "AWS::S3::Bucket".to_string(),
2434                status: "CREATE_COMPLETE".to_string(),
2435                module_info: String::new(),
2436            },
2437            StackResource {
2438                logical_id: "MyFunction".to_string(),
2439                physical_id: "my-function-456".to_string(),
2440                resource_type: "AWS::Lambda::Function".to_string(),
2441                status: "CREATE_COMPLETE".to_string(),
2442                module_info: String::new(),
2443            },
2444        ];
2445        app.cfn_state.resources.filter = String::new();
2446
2447        let filtered: Vec<&StackResource> = if app.cfn_state.resources.filter.is_empty() {
2448            app.cfn_state.resources.items.iter().collect()
2449        } else {
2450            app.cfn_state
2451                .resources
2452                .items
2453                .iter()
2454                .filter(|r| {
2455                    r.logical_id
2456                        .to_lowercase()
2457                        .contains(&app.cfn_state.resources.filter.to_lowercase())
2458                })
2459                .collect()
2460        };
2461        assert_eq!(filtered.len(), 2);
2462    }
2463
2464    #[test]
2465    fn test_filtered_resources_by_logical_id() {
2466        let mut app = test_app();
2467        app.cfn_state.resources.items = vec![
2468            StackResource {
2469                logical_id: "MyBucket".to_string(),
2470                physical_id: "my-bucket-123".to_string(),
2471                resource_type: "AWS::S3::Bucket".to_string(),
2472                status: "CREATE_COMPLETE".to_string(),
2473                module_info: String::new(),
2474            },
2475            StackResource {
2476                logical_id: "MyFunction".to_string(),
2477                physical_id: "my-function-456".to_string(),
2478                resource_type: "AWS::Lambda::Function".to_string(),
2479                status: "CREATE_COMPLETE".to_string(),
2480                module_info: String::new(),
2481            },
2482        ];
2483        app.cfn_state.resources.filter = "bucket".to_string();
2484
2485        let filtered: Vec<&StackResource> = app
2486            .cfn_state
2487            .resources
2488            .items
2489            .iter()
2490            .filter(|r| {
2491                r.logical_id
2492                    .to_lowercase()
2493                    .contains(&app.cfn_state.resources.filter.to_lowercase())
2494            })
2495            .collect();
2496        assert_eq!(filtered.len(), 1);
2497        assert_eq!(filtered[0].logical_id, "MyBucket");
2498    }
2499
2500    #[test]
2501    fn test_filtered_resources_by_type() {
2502        let mut app = test_app();
2503        app.cfn_state.resources.items = vec![
2504            StackResource {
2505                logical_id: "MyBucket".to_string(),
2506                physical_id: "my-bucket-123".to_string(),
2507                resource_type: "AWS::S3::Bucket".to_string(),
2508                status: "CREATE_COMPLETE".to_string(),
2509                module_info: String::new(),
2510            },
2511            StackResource {
2512                logical_id: "MyFunction".to_string(),
2513                physical_id: "my-function-456".to_string(),
2514                resource_type: "AWS::Lambda::Function".to_string(),
2515                status: "CREATE_COMPLETE".to_string(),
2516                module_info: String::new(),
2517            },
2518        ];
2519        app.cfn_state.resources.filter = "lambda".to_string();
2520
2521        let filtered: Vec<&StackResource> = app
2522            .cfn_state
2523            .resources
2524            .items
2525            .iter()
2526            .filter(|r| {
2527                r.resource_type
2528                    .to_lowercase()
2529                    .contains(&app.cfn_state.resources.filter.to_lowercase())
2530            })
2531            .collect();
2532        assert_eq!(filtered.len(), 1);
2533        assert_eq!(filtered[0].logical_id, "MyFunction");
2534    }
2535
2536    #[test]
2537    fn test_resources_sorted_by_logical_id() {
2538        let mut app = test_app();
2539        app.cfn_state.resources.items = vec![
2540            StackResource {
2541                logical_id: "ZBucket".to_string(),
2542                physical_id: "z-bucket".to_string(),
2543                resource_type: "AWS::S3::Bucket".to_string(),
2544                status: "CREATE_COMPLETE".to_string(),
2545                module_info: String::new(),
2546            },
2547            StackResource {
2548                logical_id: "AFunction".to_string(),
2549                physical_id: "a-function".to_string(),
2550                resource_type: "AWS::Lambda::Function".to_string(),
2551                status: "CREATE_COMPLETE".to_string(),
2552                module_info: String::new(),
2553            },
2554        ];
2555
2556        // Resources should be sorted by logical_id in the API response
2557        // but let's verify the order
2558        assert_eq!(app.cfn_state.resources.items[0].logical_id, "ZBucket");
2559        assert_eq!(app.cfn_state.resources.items[1].logical_id, "AFunction");
2560    }
2561
2562    #[test]
2563    fn test_resources_expansion() {
2564        let mut app = test_app();
2565        app.cfn_state.resources.items = vec![StackResource {
2566            logical_id: "MyBucket".to_string(),
2567            physical_id: "my-bucket-123".to_string(),
2568            resource_type: "AWS::S3::Bucket".to_string(),
2569            status: "CREATE_COMPLETE".to_string(),
2570            module_info: String::new(),
2571        }];
2572
2573        assert_eq!(app.cfn_state.resources.expanded_item, None);
2574
2575        // Expand
2576        app.cfn_state.resources.toggle_expand();
2577        assert_eq!(app.cfn_state.resources.expanded_item, Some(0));
2578
2579        // Collapse
2580        app.cfn_state.resources.collapse();
2581        assert_eq!(app.cfn_state.resources.expanded_item, None);
2582    }
2583
2584    #[test]
2585    fn test_resource_column_ids() {
2586        let ids = resource_column_ids();
2587        assert_eq!(ids.len(), 5);
2588        assert!(ids.contains(&"cfn.resource.logical_id"));
2589        assert!(ids.contains(&"cfn.resource.physical_id"));
2590        assert!(ids.contains(&"cfn.resource.type"));
2591        assert!(ids.contains(&"cfn.resource.status"));
2592        assert!(ids.contains(&"cfn.resource.module"));
2593    }
2594
2595    #[test]
2596    fn test_detail_tab_allows_preferences() {
2597        assert!(DetailTab::StackInfo.allows_preferences());
2598        assert!(DetailTab::Parameters.allows_preferences());
2599        assert!(DetailTab::Outputs.allows_preferences());
2600        assert!(DetailTab::Resources.allows_preferences());
2601        assert!(DetailTab::Events.allows_preferences());
2602        assert!(DetailTab::ChangeSets.allows_preferences());
2603        assert!(!DetailTab::Template.allows_preferences());
2604        assert!(!DetailTab::GitSync.allows_preferences());
2605    }
2606
2607    #[test]
2608    fn test_resources_tree_view_expansion() {
2609        let mut app = test_app();
2610        app.cfn_state.resources.items = vec![
2611            StackResource {
2612                logical_id: "ParentModule".to_string(),
2613                physical_id: "parent-123".to_string(),
2614                resource_type: "AWS::CloudFormation::Stack".to_string(),
2615                status: "CREATE_COMPLETE".to_string(),
2616                module_info: String::new(),
2617            },
2618            StackResource {
2619                logical_id: "ChildResource".to_string(),
2620                physical_id: "child-456".to_string(),
2621                resource_type: "AWS::S3::Bucket".to_string(),
2622                status: "CREATE_COMPLETE".to_string(),
2623                module_info: "ParentModule".to_string(),
2624            },
2625        ];
2626
2627        // Initially not expanded
2628        assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2629
2630        // Expand parent
2631        app.cfn_state
2632            .expanded_items
2633            .insert("ParentModule".to_string());
2634        assert!(app.cfn_state.expanded_items.contains("ParentModule"));
2635
2636        // Collapse parent
2637        app.cfn_state.expanded_items.remove("ParentModule");
2638        assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2639    }
2640
2641    #[test]
2642    fn test_resources_navigation() {
2643        use crate::app::Service;
2644        let mut app = test_app();
2645        app.current_service = Service::CloudFormationStacks;
2646        app.cfn_state.current_stack = Some("test-stack".to_string());
2647        app.cfn_state.detail_tab = DetailTab::Resources;
2648        app.cfn_state.resources.items = vec![
2649            StackResource {
2650                logical_id: "Resource1".to_string(),
2651                physical_id: "res-1".to_string(),
2652                resource_type: "AWS::S3::Bucket".to_string(),
2653                status: "CREATE_COMPLETE".to_string(),
2654                module_info: String::new(),
2655            },
2656            StackResource {
2657                logical_id: "Resource2".to_string(),
2658                physical_id: "res-2".to_string(),
2659                resource_type: "AWS::Lambda::Function".to_string(),
2660                status: "CREATE_COMPLETE".to_string(),
2661                module_info: String::new(),
2662            },
2663        ];
2664
2665        assert_eq!(app.cfn_state.resources.selected, 0);
2666
2667        // Navigate down
2668        let filtered = filtered_resources(&app);
2669        app.cfn_state.resources.next_item(filtered.len());
2670        assert_eq!(app.cfn_state.resources.selected, 1);
2671
2672        // Navigate up
2673        app.cfn_state.resources.prev_item();
2674        assert_eq!(app.cfn_state.resources.selected, 0);
2675    }
2676
2677    #[test]
2678    fn test_resources_filter() {
2679        let mut app = test_app();
2680        app.cfn_state.resources.items = vec![
2681            StackResource {
2682                logical_id: "MyBucket".to_string(),
2683                physical_id: "my-bucket-123".to_string(),
2684                resource_type: "AWS::S3::Bucket".to_string(),
2685                status: "CREATE_COMPLETE".to_string(),
2686                module_info: String::new(),
2687            },
2688            StackResource {
2689                logical_id: "MyFunction".to_string(),
2690                physical_id: "my-function-456".to_string(),
2691                resource_type: "AWS::Lambda::Function".to_string(),
2692                status: "CREATE_COMPLETE".to_string(),
2693                module_info: String::new(),
2694            },
2695        ];
2696
2697        // No filter
2698        app.cfn_state.resources.filter = String::new();
2699        let filtered = filtered_resources(&app);
2700        assert_eq!(filtered.len(), 2);
2701
2702        // Filter by logical ID
2703        app.cfn_state.resources.filter = "bucket".to_string();
2704        let filtered = filtered_resources(&app);
2705        assert_eq!(filtered.len(), 1);
2706        assert_eq!(filtered[0].logical_id, "MyBucket");
2707
2708        // Filter by type
2709        app.cfn_state.resources.filter = "lambda".to_string();
2710        let filtered = filtered_resources(&app);
2711        assert_eq!(filtered.len(), 1);
2712        assert_eq!(filtered[0].logical_id, "MyFunction");
2713    }
2714
2715    #[test]
2716    fn test_resources_page_size() {
2717        use crate::common::PageSize;
2718        let mut app = test_app();
2719
2720        // Default page size
2721        assert_eq!(app.cfn_state.resources.page_size, PageSize::Fifty);
2722        assert_eq!(app.cfn_state.resources.page_size.value(), 50);
2723
2724        // Change page size
2725        app.cfn_state.resources.page_size = PageSize::TwentyFive;
2726        assert_eq!(app.cfn_state.resources.page_size, PageSize::TwentyFive);
2727        assert_eq!(app.cfn_state.resources.page_size.value(), 25);
2728    }
2729}
2730
2731#[cfg(test)]
2732mod events_tests {
2733    use super::*;
2734    use crate::app::App;
2735    use crate::ui::table::Column;
2736    use ratatui::style::Color;
2737    use rusticity_core::cfn::StackEvent;
2738
2739    fn test_app() -> App {
2740        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
2741    }
2742
2743    fn make_event(status: &str) -> StackEvent {
2744        StackEvent {
2745            event_id: "evt-001".to_string(),
2746            timestamp: "2024-01-01T12:00:00Z".to_string(),
2747            logical_id: "MyBucket".to_string(),
2748            status: status.to_string(),
2749            detailed_status: String::new(),
2750            status_reason: "Resource creation complete".to_string(),
2751            hook_invocation_count: String::new(),
2752            resource_type: "AWS::S3::Bucket".to_string(),
2753            physical_id: "my-bucket-123".to_string(),
2754            client_request_token: "token-abc".to_string(),
2755            operation_id: String::new(),
2756        }
2757    }
2758
2759    #[test]
2760    fn test_event_status_column_uses_format_status() {
2761        let col = EventColumn::Status;
2762
2763        let e = make_event("CREATE_COMPLETE");
2764        let (text, style) = col.render(&e);
2765        assert!(
2766            text.contains("✅"),
2767            "CREATE_COMPLETE should have ✅, got: {text}"
2768        );
2769        assert_eq!(style.fg, Some(Color::Green));
2770
2771        let e = make_event("CREATE_FAILED");
2772        let (text, style) = col.render(&e);
2773        assert!(
2774            text.contains("❌"),
2775            "CREATE_FAILED should have ❌, got: {text}"
2776        );
2777        assert_eq!(style.fg, Some(Color::Red));
2778
2779        let e = make_event("UPDATE_IN_PROGRESS");
2780        let (text, style) = col.render(&e);
2781        assert!(
2782            text.contains("ℹ️"),
2783            "UPDATE_IN_PROGRESS should have ℹ️, got: {text}"
2784        );
2785        assert_eq!(style.fg, Some(Color::Blue));
2786    }
2787
2788    #[test]
2789    fn test_event_default_visible_columns_excludes_non_default() {
2790        let ids = event_visible_column_ids();
2791        let all_ids = event_column_ids();
2792
2793        // Default visible MUST include OperationId (user spec: OperationId IS enabled by default)
2794        assert!(
2795            ids.contains(&EventColumn::OperationId.id()),
2796            "OperationId must be visible by default"
2797        );
2798
2799        // Default visible must NOT include PhysicalId, ClientRequestToken, HookInvocationCount, Type
2800        assert!(
2801            !ids.contains(&EventColumn::PhysicalId.id()),
2802            "PhysicalId should not be visible by default"
2803        );
2804        assert!(
2805            !ids.contains(&EventColumn::ClientRequestToken.id()),
2806            "ClientRequestToken should not be visible by default"
2807        );
2808        assert!(
2809            !ids.contains(&EventColumn::HookInvocationCount.id()),
2810            "HookInvocationCount should not be visible by default"
2811        );
2812        assert!(
2813            !ids.contains(&EventColumn::Type.id()),
2814            "Type should not be visible by default"
2815        );
2816
2817        // Default visible MUST include Timestamp, LogicalId, Status, DetailedStatus, StatusReason
2818        assert!(
2819            ids.contains(&EventColumn::Timestamp.id()),
2820            "Timestamp must be visible by default"
2821        );
2822        assert!(
2823            ids.contains(&EventColumn::LogicalId.id()),
2824            "LogicalId must be visible by default"
2825        );
2826        assert!(
2827            ids.contains(&EventColumn::Status.id()),
2828            "Status must be visible by default"
2829        );
2830        assert!(
2831            ids.contains(&EventColumn::DetailedStatus.id()),
2832            "DetailedStatus must be visible by default"
2833        );
2834        assert!(
2835            ids.contains(&EventColumn::StatusReason.id()),
2836            "StatusReason must be visible by default"
2837        );
2838
2839        // All columns list must include everything
2840        assert_eq!(all_ids.len(), EventColumn::all().len());
2841    }
2842
2843    #[test]
2844    fn test_event_column_ids_roundtrip() {
2845        for col in EventColumn::all() {
2846            let id = col.id();
2847            let roundtrip = EventColumn::from_id(id);
2848            assert_eq!(roundtrip, Some(col), "from_id({id}) should return {col:?}");
2849        }
2850    }
2851
2852    #[test]
2853    fn test_event_column_sorted_by_timestamp_desc() {
2854        // Verify the render config passes sort_column = "Timestamp" and Desc.
2855        // We test indirectly: the Timestamp column default_name must be "Timestamp".
2856        assert_eq!(EventColumn::Timestamp.default_name(), "Timestamp");
2857    }
2858
2859    #[test]
2860    fn test_events_view_default_is_table() {
2861        let app = test_app();
2862        assert_eq!(
2863            app.cfn_state.events_view,
2864            EventsView::Table,
2865            "Default events view must be Table"
2866        );
2867    }
2868
2869    #[test]
2870    fn test_toggle_events_view() {
2871        let mut app = test_app();
2872        app.current_service = crate::app::Service::CloudFormationStacks;
2873        app.cfn_state.current_stack = Some("my-stack".to_string());
2874        app.cfn_state.detail_tab = DetailTab::Events;
2875
2876        assert_eq!(app.cfn_state.events_view, EventsView::Table);
2877        crate::cfn::actions::toggle_events_view(&mut app);
2878        assert_eq!(app.cfn_state.events_view, EventsView::Timeline);
2879        crate::cfn::actions::toggle_events_view(&mut app);
2880        assert_eq!(app.cfn_state.events_view, EventsView::Table);
2881    }
2882
2883    #[test]
2884    fn test_filtered_events_by_logical_id() {
2885        let mut app = test_app();
2886        app.cfn_state.events.items = vec![make_event("CREATE_COMPLETE"), {
2887            let mut e = make_event("DELETE_IN_PROGRESS");
2888            e.logical_id = "OtherResource".to_string();
2889            e
2890        }];
2891        app.cfn_state.events.filter = "MyBucket".to_string();
2892
2893        let filtered = filtered_events(&app);
2894        assert_eq!(filtered.len(), 1);
2895        assert_eq!(filtered[0].logical_id, "MyBucket");
2896    }
2897
2898    #[test]
2899    fn test_column_selector_max_events_tab() {
2900        let mut app = test_app();
2901        app.cfn_state.current_stack = Some("my-stack".to_string());
2902        app.cfn_state.detail_tab = DetailTab::Events;
2903
2904        let max = crate::cfn::actions::column_selector_max(&app);
2905        let count = crate::cfn::actions::column_count(&app);
2906        assert_eq!(count, EventColumn::all().len());
2907        assert_eq!(max, count + 6);
2908    }
2909
2910    #[test]
2911    fn test_events_allows_preferences() {
2912        assert!(
2913            DetailTab::Events.allows_preferences(),
2914            "Events tab must allow preferences (column selector)"
2915        );
2916    }
2917
2918    #[test]
2919    fn test_events_expand_collapse() {
2920        // Regression: expand_row in app.rs had no CloudFormationStacks arm — Enter never expanded.
2921        let mut app = test_app();
2922        app.current_service = crate::app::Service::CloudFormationStacks;
2923        app.service_selected = true;
2924        app.cfn_state.current_stack = Some("my-stack".to_string());
2925        app.cfn_state.detail_tab = DetailTab::Events;
2926        app.cfn_state.events.items = vec![make_event("CREATE_COMPLETE")];
2927        app.cfn_state.events.selected = 0;
2928
2929        assert_eq!(app.cfn_state.events.expanded_item, None, "starts collapsed");
2930
2931        app.handle_action(crate::keymap::Action::ExpandRow);
2932        assert_eq!(
2933            app.cfn_state.events.expanded_item,
2934            Some(0),
2935            "Enter must expand the selected event row"
2936        );
2937
2938        app.handle_action(crate::keymap::Action::CollapseRow);
2939        assert_eq!(
2940            app.cfn_state.events.expanded_item, None,
2941            "Left arrow must collapse the expanded row"
2942        );
2943    }
2944
2945    #[test]
2946    fn test_events_column_selector_shows_all_columns() {
2947        // Regression: Events tab was missing from render_column_selector in ui/mod.rs —
2948        // opening Preferences on Events tab showed an empty panel.
2949        // Verify all 10 EventColumns are available via event_column_ids().
2950        let ids = event_column_ids();
2951        assert_eq!(
2952            ids.len(),
2953            EventColumn::all().len(),
2954            "event_column_ids must return all {} columns",
2955            EventColumn::all().len()
2956        );
2957        // Verify every column can round-trip through from_id
2958        for id in &ids {
2959            assert!(
2960                EventColumn::from_id(id).is_some(),
2961                "EventColumn::from_id({id}) must succeed"
2962            );
2963        }
2964    }
2965
2966    #[test]
2967    fn test_events_timestamp_uses_utc_format() {
2968        // Regression: Timestamp column was returning raw ISO string instead of formatted UTC.
2969        // format_iso_timestamp("2024-01-15T12:30:45Z") → "2024-01-15 12:30:45 (UTC)"
2970        let col = EventColumn::Timestamp;
2971        let e = StackEvent {
2972            event_id: "evt".into(),
2973            timestamp: "2024-01-15T12:30:45Z".into(),
2974            logical_id: "R".into(),
2975            status: "CREATE_COMPLETE".into(),
2976            detailed_status: String::new(),
2977            status_reason: String::new(),
2978            hook_invocation_count: String::new(),
2979            resource_type: String::new(),
2980            physical_id: String::new(),
2981            client_request_token: String::new(),
2982            operation_id: String::new(),
2983        };
2984        let (text, _) = col.render(&e);
2985        assert!(
2986            text.contains("(UTC)"),
2987            "Timestamp must be formatted with (UTC), got: {text}"
2988        );
2989        assert!(
2990            text.contains("2024-01-15"),
2991            "Timestamp must contain the date, got: {text}"
2992        );
2993        assert!(
2994            text.contains("12:30:45"),
2995            "Timestamp must contain the time, got: {text}"
2996        );
2997    }
2998
2999    #[test]
3000    fn test_events_timestamp_column_width_is_utc_width() {
3001        use crate::common::UTC_TIMESTAMP_WIDTH;
3002        let col = EventColumn::Timestamp;
3003        assert_eq!(
3004            col.width(),
3005            UTC_TIMESTAMP_WIDTH,
3006            "Timestamp column width must equal UTC_TIMESTAMP_WIDTH ({UTC_TIMESTAMP_WIDTH})"
3007        );
3008    }
3009}