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