rusticity_term/ui/
cfn.rs

1use crate::app::App;
2use crate::cfn::{Column as CfnColumn, Stack as CfnStack};
3use crate::common::{
4    render_dropdown, render_pagination_text, translate_column, ColumnId, CyclicEnum, InputFocus,
5    SortDirection,
6};
7use crate::keymap::Mode;
8use crate::table::TableState;
9use crate::ui::table::Column;
10use crate::ui::{labeled_field, render_tabs, rounded_block};
11use ratatui::{prelude::*, widgets::*};
12use rusticity_core::cfn::{StackOutput, StackParameter, StackResource};
13use std::collections::HashSet;
14
15pub const STATUS_FILTER: InputFocus = InputFocus::Dropdown("StatusFilter");
16pub const VIEW_NESTED: InputFocus = InputFocus::Checkbox("ViewNested");
17
18impl State {
19    pub const FILTER_CONTROLS: [InputFocus; 4] = [
20        InputFocus::Filter,
21        STATUS_FILTER,
22        VIEW_NESTED,
23        InputFocus::Pagination,
24    ];
25
26    pub const PARAMETERS_FILTER_CONTROLS: [InputFocus; 2] =
27        [InputFocus::Filter, InputFocus::Pagination];
28
29    pub const OUTPUTS_FILTER_CONTROLS: [InputFocus; 2] =
30        [InputFocus::Filter, InputFocus::Pagination];
31
32    pub const RESOURCES_FILTER_CONTROLS: [InputFocus; 2] =
33        [InputFocus::Filter, InputFocus::Pagination];
34}
35
36pub struct State {
37    pub table: TableState<CfnStack>,
38    pub input_focus: InputFocus,
39    pub status_filter: StatusFilter,
40    pub view_nested: bool,
41    pub current_stack: Option<String>,
42    pub detail_tab: DetailTab,
43    pub overview_scroll: u16,
44    pub sort_column: CfnColumn,
45    pub sort_direction: SortDirection,
46    pub template_body: String,
47    pub template_scroll: usize,
48    pub parameters: TableState<StackParameter>,
49    pub parameters_input_focus: InputFocus,
50    pub outputs: TableState<StackOutput>,
51    pub outputs_input_focus: InputFocus,
52    pub tags: TableState<(String, String)>,
53    pub policy_scroll: usize,
54    /// Tracks expanded items for hierarchical views (Resources, Events tabs).
55    /// Keys are resource IDs or logical resource names that are currently expanded.
56    pub expanded_items: HashSet<String>,
57    pub resources: TableState<StackResource>,
58    pub resources_input_focus: InputFocus,
59}
60
61impl Default for State {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl State {
68    pub fn new() -> Self {
69        Self {
70            table: TableState::new(),
71            input_focus: InputFocus::Filter,
72            status_filter: StatusFilter::All,
73            view_nested: false,
74            current_stack: None,
75            detail_tab: DetailTab::StackInfo,
76            overview_scroll: 0,
77            sort_column: CfnColumn::CreatedTime,
78            sort_direction: SortDirection::Desc,
79            template_body: String::new(),
80            template_scroll: 0,
81            parameters: TableState::new(),
82            parameters_input_focus: InputFocus::Filter,
83            outputs: TableState::new(),
84            outputs_input_focus: InputFocus::Filter,
85            tags: TableState::new(),
86            policy_scroll: 0,
87            expanded_items: HashSet::new(),
88            resources: TableState::new(),
89            resources_input_focus: InputFocus::Filter,
90        }
91    }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum StatusFilter {
96    All,
97    Active,
98    Complete,
99    Failed,
100    Deleted,
101    InProgress,
102}
103
104impl StatusFilter {
105    pub fn name(&self) -> &'static str {
106        match self {
107            StatusFilter::All => "All",
108            StatusFilter::Active => "Active",
109            StatusFilter::Complete => "Complete",
110            StatusFilter::Failed => "Failed",
111            StatusFilter::Deleted => "Deleted",
112            StatusFilter::InProgress => "In progress",
113        }
114    }
115
116    pub fn all() -> Vec<StatusFilter> {
117        vec![
118            StatusFilter::All,
119            StatusFilter::Active,
120            StatusFilter::Complete,
121            StatusFilter::Failed,
122            StatusFilter::Deleted,
123            StatusFilter::InProgress,
124        ]
125    }
126}
127
128impl crate::common::CyclicEnum for StatusFilter {
129    const ALL: &'static [Self] = &[
130        Self::All,
131        Self::Active,
132        Self::Complete,
133        Self::Failed,
134        Self::Deleted,
135        Self::InProgress,
136    ];
137}
138
139impl StatusFilter {
140    pub fn matches(&self, status: &str) -> bool {
141        match self {
142            StatusFilter::All => true,
143            StatusFilter::Active => {
144                !status.contains("DELETE")
145                    && !status.contains("COMPLETE")
146                    && !status.contains("FAILED")
147            }
148            StatusFilter::Complete => status.contains("COMPLETE") && !status.contains("DELETE"),
149            StatusFilter::Failed => status.contains("FAILED"),
150            StatusFilter::Deleted => status.contains("DELETE"),
151            StatusFilter::InProgress => status.contains("IN_PROGRESS"),
152        }
153    }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub enum DetailTab {
158    StackInfo,
159    Events,
160    Resources,
161    Outputs,
162    Parameters,
163    Template,
164    ChangeSets,
165    GitSync,
166}
167
168impl CyclicEnum for DetailTab {
169    const ALL: &'static [Self] = &[
170        Self::StackInfo,
171        Self::Events,
172        Self::Resources,
173        Self::Outputs,
174        Self::Parameters,
175        Self::Template,
176        Self::ChangeSets,
177        Self::GitSync,
178    ];
179}
180
181impl DetailTab {
182    pub fn name(&self) -> &'static str {
183        match self {
184            DetailTab::StackInfo => "Stack info",
185            DetailTab::Events => "Events",
186            DetailTab::Resources => "Resources",
187            DetailTab::Outputs => "Outputs",
188            DetailTab::Parameters => "Parameters",
189            DetailTab::Template => "Template",
190            DetailTab::ChangeSets => "Change sets",
191            DetailTab::GitSync => "Git sync",
192        }
193    }
194
195    pub fn all() -> Vec<DetailTab> {
196        vec![
197            DetailTab::StackInfo,
198            DetailTab::Events,
199            DetailTab::Resources,
200            DetailTab::Outputs,
201            DetailTab::Parameters,
202            DetailTab::Template,
203            DetailTab::ChangeSets,
204            DetailTab::GitSync,
205        ]
206    }
207
208    pub fn allows_preferences(&self) -> bool {
209        matches!(
210            self,
211            DetailTab::StackInfo
212                | DetailTab::Parameters
213                | DetailTab::Outputs
214                | DetailTab::Resources
215        )
216    }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq)]
220pub enum ResourceColumn {
221    LogicalId,
222    PhysicalId,
223    Type,
224    Status,
225    Module,
226}
227
228impl ResourceColumn {
229    pub fn id(&self) -> &'static str {
230        match self {
231            ResourceColumn::LogicalId => "cfn.resource.logical_id",
232            ResourceColumn::PhysicalId => "cfn.resource.physical_id",
233            ResourceColumn::Type => "cfn.resource.type",
234            ResourceColumn::Status => "cfn.resource.status",
235            ResourceColumn::Module => "cfn.resource.module",
236        }
237    }
238
239    pub fn default_name(&self) -> &'static str {
240        match self {
241            ResourceColumn::LogicalId => "Logical ID",
242            ResourceColumn::PhysicalId => "Physical ID",
243            ResourceColumn::Type => "Type",
244            ResourceColumn::Status => "Status",
245            ResourceColumn::Module => "Module",
246        }
247    }
248
249    pub fn all() -> Vec<ResourceColumn> {
250        vec![
251            ResourceColumn::LogicalId,
252            ResourceColumn::PhysicalId,
253            ResourceColumn::Type,
254            ResourceColumn::Status,
255            ResourceColumn::Module,
256        ]
257    }
258
259    pub fn from_id(id: &str) -> Option<ResourceColumn> {
260        match id {
261            "cfn.resource.logical_id" => Some(ResourceColumn::LogicalId),
262            "cfn.resource.physical_id" => Some(ResourceColumn::PhysicalId),
263            "cfn.resource.type" => Some(ResourceColumn::Type),
264            "cfn.resource.status" => Some(ResourceColumn::Status),
265            "cfn.resource.module" => Some(ResourceColumn::Module),
266            _ => None,
267        }
268    }
269}
270
271pub fn resource_column_ids() -> Vec<ColumnId> {
272    ResourceColumn::all().iter().map(|c| c.id()).collect()
273}
274
275pub fn filtered_cloudformation_stacks(app: &App) -> Vec<&crate::cfn::Stack> {
276    let filtered: Vec<&crate::cfn::Stack> = if app.cfn_state.table.filter.is_empty() {
277        app.cfn_state.table.items.iter().collect()
278    } else {
279        app.cfn_state
280            .table
281            .items
282            .iter()
283            .filter(|s| {
284                s.name
285                    .to_lowercase()
286                    .contains(&app.cfn_state.table.filter.to_lowercase())
287                    || s.description
288                        .to_lowercase()
289                        .contains(&app.cfn_state.table.filter.to_lowercase())
290            })
291            .collect()
292    };
293
294    filtered
295        .into_iter()
296        .filter(|s| app.cfn_state.status_filter.matches(&s.status))
297        .collect()
298}
299
300pub fn parameter_column_ids() -> Vec<ColumnId> {
301    ParameterColumn::all().iter().map(|c| c.id()).collect()
302}
303
304pub fn output_column_ids() -> Vec<ColumnId> {
305    OutputColumn::all().iter().map(|c| c.id()).collect()
306}
307
308pub fn filtered_parameters(app: &App) -> Vec<&StackParameter> {
309    if app.cfn_state.parameters.filter.is_empty() {
310        app.cfn_state.parameters.items.iter().collect()
311    } else {
312        app.cfn_state
313            .parameters
314            .items
315            .iter()
316            .filter(|p| {
317                p.key
318                    .to_lowercase()
319                    .contains(&app.cfn_state.parameters.filter.to_lowercase())
320                    || p.value
321                        .to_lowercase()
322                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
323                    || p.resolved_value
324                        .to_lowercase()
325                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
326            })
327            .collect()
328    }
329}
330
331pub fn filtered_outputs(app: &App) -> Vec<&StackOutput> {
332    if app.cfn_state.outputs.filter.is_empty() {
333        app.cfn_state.outputs.items.iter().collect()
334    } else {
335        app.cfn_state
336            .outputs
337            .items
338            .iter()
339            .filter(|o| {
340                o.key
341                    .to_lowercase()
342                    .contains(&app.cfn_state.outputs.filter.to_lowercase())
343                    || o.value
344                        .to_lowercase()
345                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
346                    || o.description
347                        .to_lowercase()
348                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
349                    || o.export_name
350                        .to_lowercase()
351                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
352            })
353            .collect()
354    }
355}
356
357pub fn filtered_resources(app: &App) -> Vec<&StackResource> {
358    if app.cfn_state.resources.filter.is_empty() {
359        app.cfn_state.resources.items.iter().collect()
360    } else {
361        app.cfn_state
362            .resources
363            .items
364            .iter()
365            .filter(|r| {
366                r.logical_id
367                    .to_lowercase()
368                    .contains(&app.cfn_state.resources.filter.to_lowercase())
369                    || r.physical_id
370                        .to_lowercase()
371                        .contains(&app.cfn_state.resources.filter.to_lowercase())
372                    || r.resource_type
373                        .to_lowercase()
374                        .contains(&app.cfn_state.resources.filter.to_lowercase())
375                    || r.status
376                        .to_lowercase()
377                        .contains(&app.cfn_state.resources.filter.to_lowercase())
378                    || r.module_info
379                        .to_lowercase()
380                        .contains(&app.cfn_state.resources.filter.to_lowercase())
381            })
382            .collect()
383    }
384}
385
386pub fn filtered_tags(app: &App) -> Vec<&(String, String)> {
387    if app.cfn_state.tags.filter.is_empty() {
388        app.cfn_state.tags.items.iter().collect()
389    } else {
390        app.cfn_state
391            .tags
392            .items
393            .iter()
394            .filter(|(k, v)| {
395                k.to_lowercase()
396                    .contains(&app.cfn_state.tags.filter.to_lowercase())
397                    || v.to_lowercase()
398                        .contains(&app.cfn_state.tags.filter.to_lowercase())
399            })
400            .collect()
401    }
402}
403
404pub fn render_stacks(frame: &mut Frame, app: &App, area: Rect) {
405    frame.render_widget(Clear, area);
406
407    if app.cfn_state.current_stack.is_some() {
408        render_cloudformation_stack_detail(frame, app, area);
409    } else {
410        render_cloudformation_stack_list(frame, app, area);
411    }
412}
413
414pub fn render_cloudformation_stack_list(frame: &mut Frame, app: &App, area: Rect) {
415    let chunks = Layout::default()
416        .direction(Direction::Vertical)
417        .constraints([
418            Constraint::Length(3), // Filter + controls
419            Constraint::Min(0),    // Table
420        ])
421        .split(area);
422
423    // Filter line - search on left, controls on right
424    let filtered_stacks = filtered_cloudformation_stacks(app);
425    let filtered_count = filtered_stacks.len();
426
427    let placeholder = "Search by stack name";
428
429    let status_filter_text = format!("Filter status: {}", app.cfn_state.status_filter.name());
430    let view_nested_text = if app.cfn_state.view_nested {
431        "☑ View nested"
432    } else {
433        "☐ View nested"
434    };
435    let page_size = app.cfn_state.table.page_size.value();
436    let total_pages = filtered_count.div_ceil(page_size);
437    let current_page =
438        if filtered_count > 0 && app.cfn_state.table.scroll_offset + page_size >= filtered_count {
439            total_pages.saturating_sub(1)
440        } else {
441            app.cfn_state.table.scroll_offset / page_size
442        };
443    let pagination = render_pagination_text(current_page, total_pages);
444
445    crate::ui::filter::render_filter_bar(
446        frame,
447        crate::ui::filter::FilterConfig {
448            filter_text: &app.cfn_state.table.filter,
449            placeholder,
450            mode: app.mode,
451            is_input_focused: app.cfn_state.input_focus == InputFocus::Filter,
452            controls: vec![
453                crate::ui::filter::FilterControl {
454                    text: status_filter_text.to_string(),
455                    is_focused: app.cfn_state.input_focus == STATUS_FILTER,
456                },
457                crate::ui::filter::FilterControl {
458                    text: view_nested_text.to_string(),
459                    is_focused: app.cfn_state.input_focus == VIEW_NESTED,
460                },
461                crate::ui::filter::FilterControl {
462                    text: pagination.clone(),
463                    is_focused: app.cfn_state.input_focus == InputFocus::Pagination,
464                },
465            ],
466            area: chunks[0],
467        },
468    );
469
470    // Table - use scroll_offset for pagination
471    let scroll_offset = app.cfn_state.table.scroll_offset;
472    let page_stacks: Vec<_> = filtered_stacks
473        .iter()
474        .skip(scroll_offset)
475        .take(page_size)
476        .collect();
477
478    // Define columns
479    let column_enums: Vec<CfnColumn> = app
480        .cfn_visible_column_ids
481        .iter()
482        .filter_map(|col_id| CfnColumn::from_id(col_id))
483        .collect();
484
485    let columns: Vec<Box<dyn crate::ui::table::Column<&CfnStack>>> =
486        column_enums.iter().map(|col| col.to_column()).collect();
487
488    let expanded_index = app.cfn_state.table.expanded_item.and_then(|idx| {
489        let scroll_offset = app.cfn_state.table.scroll_offset;
490        if idx >= scroll_offset && idx < scroll_offset + page_size {
491            Some(idx - scroll_offset)
492        } else {
493            None
494        }
495    });
496
497    let config = crate::ui::table::TableConfig {
498        items: page_stacks,
499        selected_index: app.cfn_state.table.selected % app.cfn_state.table.page_size.value(),
500        expanded_index,
501        columns: &columns,
502        sort_column: app.cfn_state.sort_column.default_name(),
503        sort_direction: app.cfn_state.sort_direction,
504        title: format!(" Stacks ({}) ", filtered_count),
505        area: chunks[1],
506        get_expanded_content: Some(Box::new(|stack: &&crate::cfn::Stack| {
507            crate::ui::table::expanded_from_columns(&columns, stack)
508        })),
509        is_active: app.mode != Mode::FilterInput,
510    };
511
512    crate::ui::table::render_table(frame, config);
513
514    // Render dropdown for StatusFilter when focused (after table so it appears on top)
515    if app.mode == Mode::FilterInput && app.cfn_state.input_focus == STATUS_FILTER {
516        let filter_names: Vec<&str> = StatusFilter::all().iter().map(|f| f.name()).collect();
517        let selected_idx = StatusFilter::all()
518            .iter()
519            .position(|f| *f == app.cfn_state.status_filter)
520            .unwrap_or(0);
521        let view_nested_width = " ☑ View nested ".len() as u16;
522        let controls_after = view_nested_width + 3 + pagination.len() as u16 + 3;
523        render_dropdown(
524            frame,
525            &filter_names,
526            selected_idx,
527            chunks[0],
528            controls_after,
529        );
530    }
531}
532
533pub fn render_cloudformation_stack_detail(frame: &mut Frame, app: &App, area: Rect) {
534    let stack_name = app.cfn_state.current_stack.as_ref().unwrap();
535
536    // Find the stack
537    let stack = app
538        .cfn_state
539        .table
540        .items
541        .iter()
542        .find(|s| &s.name == stack_name);
543
544    if stack.is_none() {
545        let paragraph = Paragraph::new("Stack not found").block(rounded_block().title(" Error "));
546        frame.render_widget(paragraph, area);
547        return;
548    }
549
550    let stack = stack.unwrap();
551
552    let chunks = Layout::default()
553        .direction(Direction::Vertical)
554        .constraints([
555            Constraint::Length(1), // Stack name
556            Constraint::Length(1), // Tabs
557            Constraint::Min(0),    // Content
558        ])
559        .split(area);
560
561    // Render stack name
562    frame.render_widget(Paragraph::new(stack.name.clone()), chunks[0]);
563
564    // Render tabs
565    let tabs: Vec<_> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
566    render_tabs(frame, chunks[1], &tabs, &app.cfn_state.detail_tab);
567
568    // Render content based on selected tab
569    match app.cfn_state.detail_tab {
570        DetailTab::StackInfo => {
571            render_stack_info(frame, app, stack, chunks[2]);
572        }
573        DetailTab::GitSync => {
574            render_git_sync(frame, app, stack, chunks[2]);
575        }
576        DetailTab::Template => {
577            crate::ui::render_json_highlighted(
578                frame,
579                chunks[2],
580                &app.cfn_state.template_body,
581                app.cfn_state.template_scroll,
582                " Template ",
583                true,
584            );
585        }
586        DetailTab::Parameters => {
587            render_parameters(frame, app, chunks[2]);
588        }
589        DetailTab::Outputs => {
590            render_outputs(frame, app, chunks[2]);
591        }
592        DetailTab::Resources => {
593            render_resources(frame, app, chunks[2]);
594        }
595        _ => {
596            let paragraph =
597                Paragraph::new(format!("{} - Coming soon", app.cfn_state.detail_tab.name()))
598                    .block(rounded_block());
599            frame.render_widget(paragraph, chunks[2]);
600        }
601    }
602}
603
604pub fn render_stack_info(frame: &mut Frame, app: &App, stack: &crate::cfn::Stack, area: Rect) {
605    let (formatted_status, _status_color) = crate::cfn::format_status(&stack.status);
606
607    // Overview section
608    let fields = vec![
609        (
610            "Stack ID",
611            if stack.stack_id.is_empty() {
612                "-"
613            } else {
614                &stack.stack_id
615            },
616        ),
617        (
618            "Description",
619            if stack.description.is_empty() {
620                "-"
621            } else {
622                &stack.description
623            },
624        ),
625        ("Status", &formatted_status),
626        (
627            "Detailed status",
628            if stack.detailed_status.is_empty() {
629                "-"
630            } else {
631                &stack.detailed_status
632            },
633        ),
634        (
635            "Status reason",
636            if stack.status_reason.is_empty() {
637                "-"
638            } else {
639                &stack.status_reason
640            },
641        ),
642        (
643            "Root stack",
644            if stack.root_stack.is_empty() {
645                "-"
646            } else {
647                &stack.root_stack
648            },
649        ),
650        (
651            "Parent stack",
652            if stack.parent_stack.is_empty() {
653                "-"
654            } else {
655                &stack.parent_stack
656            },
657        ),
658        (
659            "Created time",
660            if stack.created_time.is_empty() {
661                "-"
662            } else {
663                &stack.created_time
664            },
665        ),
666        (
667            "Updated time",
668            if stack.updated_time.is_empty() {
669                "-"
670            } else {
671                &stack.updated_time
672            },
673        ),
674        (
675            "Deleted time",
676            if stack.deleted_time.is_empty() {
677                "-"
678            } else {
679                &stack.deleted_time
680            },
681        ),
682        (
683            "Drift status",
684            if stack.drift_status.is_empty() {
685                "-"
686            } else if stack.drift_status == "NOT_CHECKED" || stack.drift_status == "NotChecked" {
687                "⭕ NOT CHECKED"
688            } else {
689                &stack.drift_status
690            },
691        ),
692        (
693            "Last drift check time",
694            if stack.last_drift_check_time.is_empty() {
695                "-"
696            } else {
697                &stack.last_drift_check_time
698            },
699        ),
700        (
701            "Termination protection",
702            if stack.termination_protection {
703                "Activated"
704            } else {
705                "Disabled"
706            },
707        ),
708        (
709            "IAM role",
710            if stack.iam_role.is_empty() {
711                "-"
712            } else {
713                &stack.iam_role
714            },
715        ),
716    ];
717    let overview_height = fields.len() as u16 + 2; // +2 for borders
718
719    // Tags section - use table with filter
720    let tags_height = 12; // Fixed height for tags table
721
722    // Stack policy section - render with scrolling like template
723    let policy_height = 15; // Fixed height for policy section
724
725    // Rollback configuration section
726    let rollback_lines: Vec<Line> = {
727        let mut lines = vec![labeled_field(
728            "Monitoring time",
729            if stack.rollback_monitoring_time.is_empty() {
730                "-"
731            } else {
732                &stack.rollback_monitoring_time
733            },
734        )];
735
736        if stack.rollback_alarms.is_empty() {
737            lines.push(Line::from("CloudWatch alarm ARN: No alarms configured"));
738        } else {
739            for alarm in &stack.rollback_alarms {
740                lines.push(Line::from(format!("CloudWatch alarm ARN: {}", alarm)));
741            }
742        }
743        lines
744    };
745    let rollback_height = rollback_lines.len() as u16 + 2;
746
747    // Notification options section
748    let notification_lines = if stack.notification_arns.is_empty() {
749        vec!["SNS topic ARN: No notifications configured".to_string()]
750    } else {
751        stack
752            .notification_arns
753            .iter()
754            .map(|arn| format!("SNS topic ARN: {}", arn))
755            .collect()
756    };
757    let notification_height = notification_lines.len() as u16 + 2; // +2 for borders
758
759    // Split into sections with calculated heights
760    let sections = Layout::default()
761        .direction(Direction::Vertical)
762        .constraints([
763            Constraint::Length(overview_height),
764            Constraint::Length(tags_height),
765            Constraint::Length(policy_height),
766            Constraint::Length(rollback_height),
767            Constraint::Length(notification_height),
768            Constraint::Min(0), // Remaining space
769        ])
770        .split(area);
771
772    // Render overview
773    let overview_lines: Vec<_> = fields
774        .iter()
775        .map(|(label, value)| labeled_field(label, *value))
776        .collect();
777    let overview = Paragraph::new(overview_lines)
778        .block(crate::ui::rounded_block().title(" Overview "))
779        .wrap(Wrap { trim: true });
780    frame.render_widget(overview, sections[0]);
781
782    // Render tags table
783    render_tags(frame, app, sections[1]);
784
785    // Render stack policy with scrolling
786    let policy_text = if stack.stack_policy.is_empty() {
787        "No stack policy".to_string()
788    } else {
789        stack.stack_policy.clone()
790    };
791    crate::ui::render_json_highlighted(
792        frame,
793        sections[2],
794        &policy_text,
795        app.cfn_state.policy_scroll,
796        " Stack policy ",
797        true,
798    );
799
800    // Render rollback configuration
801    let rollback = Paragraph::new(rollback_lines)
802        .block(
803            Block::default()
804                .borders(Borders::ALL)
805                .border_type(BorderType::Rounded)
806                .title(" Rollback configuration "),
807        )
808        .wrap(Wrap { trim: true });
809    frame.render_widget(rollback, sections[3]);
810
811    // Render notification options
812    let notifications = Paragraph::new(notification_lines.join("\n"))
813        .block(
814            Block::default()
815                .borders(Borders::ALL)
816                .border_type(BorderType::Rounded)
817                .title(" Notification options "),
818        )
819        .wrap(Wrap { trim: true });
820    frame.render_widget(notifications, sections[4]);
821}
822
823fn render_tags(frame: &mut Frame, app: &App, area: Rect) {
824    let chunks = Layout::default()
825        .direction(Direction::Vertical)
826        .constraints([Constraint::Length(3), Constraint::Min(0)])
827        .split(area);
828
829    let filtered: Vec<&(String, String)> = filtered_tags(app);
830    let filtered_count = filtered.len();
831
832    crate::ui::filter::render_filter_bar(
833        frame,
834        crate::ui::filter::FilterConfig {
835            filter_text: &app.cfn_state.tags.filter,
836            placeholder: "Search tags",
837            mode: app.mode,
838            is_input_focused: false,
839            controls: vec![],
840            area: chunks[0],
841        },
842    );
843
844    let page_size = app.cfn_state.tags.page_size.value();
845    let page_start = app.cfn_state.tags.scroll_offset;
846    let page_end = (page_start + page_size).min(filtered_count);
847    let page_tags: Vec<_> = filtered[page_start..page_end].to_vec();
848
849    let columns: Vec<Box<dyn Column<(String, String)>>> =
850        vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
851
852    let expanded_index = app.cfn_state.tags.expanded_item.and_then(|idx| {
853        let scroll_offset = app.cfn_state.tags.scroll_offset;
854        if idx >= scroll_offset && idx < scroll_offset + page_size {
855            Some(idx - scroll_offset)
856        } else {
857            None
858        }
859    });
860
861    let config = crate::ui::table::TableConfig {
862        items: page_tags,
863        selected_index: app.cfn_state.tags.selected % page_size,
864        expanded_index,
865        columns: &columns,
866        sort_column: "Key",
867        sort_direction: SortDirection::Asc,
868        title: format!(" Tags ({}) ", filtered_count),
869        area: chunks[1],
870        get_expanded_content: Some(Box::new(|tag: &(String, String)| {
871            crate::ui::table::expanded_from_columns(&columns, tag)
872        })),
873        is_active: true,
874    };
875
876    crate::ui::table::render_table(frame, config);
877}
878
879fn render_git_sync(frame: &mut Frame, _app: &App, _stack: &crate::cfn::Stack, area: Rect) {
880    let fields = [
881        ("Repository", "-"),
882        ("Deployment file path", "-"),
883        ("Git sync", "-"),
884        ("Repository provider", "-"),
885        ("Repository sync status", "-"),
886        ("Provisioning status", "-"),
887        ("Branch", "-"),
888        ("Repository sync status message", "-"),
889    ];
890
891    let git_sync_height = crate::ui::block_height_for(fields.len());
892
893    let sections = Layout::default()
894        .direction(Direction::Vertical)
895        .constraints([Constraint::Length(git_sync_height), Constraint::Min(0)])
896        .split(area);
897
898    let lines: Vec<Line> = fields
899        .iter()
900        .map(|&(label, value)| labeled_field(label, value))
901        .collect();
902
903    let block = rounded_block().title(" Git sync ");
904    let paragraph = Paragraph::new(lines).block(block);
905    frame.render_widget(paragraph, sections[0]);
906}
907
908#[cfg(test)]
909mod tests {
910    use super::{filtered_tags, State};
911    use crate::app::App;
912
913    fn test_app() -> App {
914        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
915    }
916
917    #[test]
918    fn test_drift_status_not_checked_formatting() {
919        let drift_status = "NOT_CHECKED";
920        let formatted = if drift_status == "NOT_CHECKED" {
921            "⭕ NOT CHECKED"
922        } else {
923            drift_status
924        };
925        assert_eq!(formatted, "⭕ NOT CHECKED");
926    }
927
928    #[test]
929    fn test_drift_status_not_checked_pascal_case() {
930        let drift_status = "NotChecked";
931        let formatted = if drift_status == "NOT_CHECKED" || drift_status == "NotChecked" {
932            "⭕ NOT CHECKED"
933        } else {
934            drift_status
935        };
936        assert_eq!(formatted, "⭕ NOT CHECKED");
937    }
938
939    #[test]
940    fn test_drift_status_other_values() {
941        let drift_status = "IN_SYNC";
942        let formatted = if drift_status == "NOT_CHECKED" {
943            "⭕ NOT CHECKED"
944        } else {
945            drift_status
946        };
947        assert_eq!(formatted, "IN_SYNC");
948    }
949
950    #[test]
951    fn test_git_sync_renders_all_fields() {
952        use crate::cfn::Stack;
953        let stack = Stack {
954            name: "test-stack".to_string(),
955            stack_id: "id".to_string(),
956            status: "CREATE_COMPLETE".to_string(),
957            created_time: String::new(),
958            updated_time: String::new(),
959            deleted_time: String::new(),
960            drift_status: String::new(),
961            last_drift_check_time: String::new(),
962            status_reason: String::new(),
963            description: String::new(),
964            detailed_status: String::new(),
965            root_stack: String::new(),
966            parent_stack: String::new(),
967            termination_protection: false,
968            iam_role: String::new(),
969            tags: Vec::new(),
970            stack_policy: String::new(),
971            rollback_monitoring_time: String::new(),
972            rollback_alarms: Vec::new(),
973            notification_arns: Vec::new(),
974        };
975
976        // Verify the fields are defined
977        let fields = [
978            "Repository",
979            "Deployment file path",
980            "Git sync",
981            "Repository provider",
982            "Repository sync status",
983            "Provisioning status",
984            "Branch",
985            "Repository sync status message",
986        ];
987
988        assert_eq!(fields.len(), 8);
989        assert_eq!(stack.name, "test-stack");
990    }
991
992    #[test]
993    fn test_git_sync_block_height() {
994        use crate::ui::block_height_for;
995
996        // Git sync has 8 labeled fields
997        let field_count = 8;
998        let expected_height = field_count + 2; // +2 for borders
999
1000        assert_eq!(block_height_for(field_count), expected_height as u16);
1001        assert_eq!(block_height_for(field_count), 10);
1002    }
1003
1004    #[test]
1005    fn test_notification_arn_format() {
1006        use crate::cfn::Stack;
1007
1008        // Test with notification ARNs
1009        let stack_with_notifications = Stack {
1010            name: "test-stack".to_string(),
1011            stack_id: "id".to_string(),
1012            status: "CREATE_COMPLETE".to_string(),
1013            created_time: String::new(),
1014            updated_time: String::new(),
1015            deleted_time: String::new(),
1016            drift_status: String::new(),
1017            last_drift_check_time: String::new(),
1018            status_reason: String::new(),
1019            description: String::new(),
1020            detailed_status: String::new(),
1021            root_stack: String::new(),
1022            parent_stack: String::new(),
1023            termination_protection: false,
1024            iam_role: String::new(),
1025            tags: Vec::new(),
1026            stack_policy: String::new(),
1027            rollback_monitoring_time: String::new(),
1028            rollback_alarms: Vec::new(),
1029            notification_arns: vec![
1030                "arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications".to_string(),
1031            ],
1032        };
1033
1034        let notification_lines: Vec<String> = stack_with_notifications
1035            .notification_arns
1036            .iter()
1037            .map(|arn| format!("SNS topic ARN: {}", arn))
1038            .collect();
1039
1040        assert_eq!(notification_lines.len(), 1);
1041        assert_eq!(
1042            notification_lines[0],
1043            "SNS topic ARN: arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications"
1044        );
1045
1046        // Test with no notifications
1047        let stack_without_notifications = Stack {
1048            name: "test-stack".to_string(),
1049            stack_id: "id".to_string(),
1050            status: "CREATE_COMPLETE".to_string(),
1051            created_time: String::new(),
1052            updated_time: String::new(),
1053            deleted_time: String::new(),
1054            drift_status: String::new(),
1055            last_drift_check_time: String::new(),
1056            status_reason: String::new(),
1057            description: String::new(),
1058            detailed_status: String::new(),
1059            root_stack: String::new(),
1060            parent_stack: String::new(),
1061            termination_protection: false,
1062            iam_role: String::new(),
1063            tags: Vec::new(),
1064            stack_policy: String::new(),
1065            rollback_monitoring_time: String::new(),
1066            rollback_alarms: Vec::new(),
1067            notification_arns: Vec::new(),
1068        };
1069
1070        let notification_lines: Vec<String> =
1071            if stack_without_notifications.notification_arns.is_empty() {
1072                vec!["SNS topic ARN: No notifications configured".to_string()]
1073            } else {
1074                stack_without_notifications
1075                    .notification_arns
1076                    .iter()
1077                    .map(|arn| format!("SNS topic ARN: {}", arn))
1078                    .collect()
1079            };
1080
1081        assert_eq!(notification_lines.len(), 1);
1082        assert_eq!(
1083            notification_lines[0],
1084            "SNS topic ARN: No notifications configured"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_template_scroll_state() {
1090        let state = State::new();
1091        assert_eq!(state.template_body, "");
1092        assert_eq!(state.template_scroll, 0);
1093    }
1094
1095    #[test]
1096    fn test_template_body_storage() {
1097        let mut state = State::new();
1098        let template = r#"{"AWSTemplateFormatVersion":"2010-09-09","Resources":{}}"#;
1099        state.template_body = template.to_string();
1100        assert_eq!(state.template_body, template);
1101    }
1102
1103    #[test]
1104    fn test_rollback_alarm_format() {
1105        let alarm_arn = "arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm";
1106        let formatted = format!("CloudWatch alarm ARN: {}", alarm_arn);
1107        assert_eq!(
1108            formatted,
1109            "CloudWatch alarm ARN: arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm"
1110        );
1111    }
1112
1113    #[test]
1114    fn test_filtered_tags() {
1115        let mut app = test_app();
1116        app.cfn_state.tags.items = vec![
1117            ("Environment".to_string(), "Production".to_string()),
1118            ("Application".to_string(), "WebApp".to_string()),
1119            ("Owner".to_string(), "TeamA".to_string()),
1120        ];
1121
1122        // No filter
1123        app.cfn_state.tags.filter = String::new();
1124        let filtered = filtered_tags(&app);
1125        assert_eq!(filtered.len(), 3);
1126
1127        // Filter by key
1128        app.cfn_state.tags.filter = "env".to_string();
1129        let filtered = filtered_tags(&app);
1130        assert_eq!(filtered.len(), 1);
1131        assert_eq!(filtered[0].0, "Environment");
1132
1133        // Filter by value
1134        app.cfn_state.tags.filter = "prod".to_string();
1135        let filtered = filtered_tags(&app);
1136        assert_eq!(filtered.len(), 1);
1137        assert_eq!(filtered[0].1, "Production");
1138    }
1139
1140    #[test]
1141    fn test_tags_sorted_by_key() {
1142        let mut tags = [
1143            ("Zebra".to_string(), "value1".to_string()),
1144            ("Alpha".to_string(), "value2".to_string()),
1145            ("Beta".to_string(), "value3".to_string()),
1146        ];
1147        tags.sort_by(|a, b| a.0.cmp(&b.0));
1148        assert_eq!(tags[0].0, "Alpha");
1149        assert_eq!(tags[1].0, "Beta");
1150        assert_eq!(tags[2].0, "Zebra");
1151    }
1152
1153    #[test]
1154    fn test_policy_scroll_state() {
1155        let state = State::new();
1156        assert_eq!(state.policy_scroll, 0);
1157    }
1158}
1159
1160pub fn render_parameters(frame: &mut Frame, app: &App, area: Rect) {
1161    let chunks = Layout::default()
1162        .direction(Direction::Vertical)
1163        .constraints([Constraint::Length(3), Constraint::Min(0)])
1164        .split(area);
1165
1166    let filtered: Vec<&StackParameter> = if app.cfn_state.parameters.filter.is_empty() {
1167        app.cfn_state.parameters.items.iter().collect()
1168    } else {
1169        app.cfn_state
1170            .parameters
1171            .items
1172            .iter()
1173            .filter(|p| {
1174                p.key
1175                    .to_lowercase()
1176                    .contains(&app.cfn_state.parameters.filter.to_lowercase())
1177                    || p.value
1178                        .to_lowercase()
1179                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
1180                    || p.resolved_value
1181                        .to_lowercase()
1182                        .contains(&app.cfn_state.parameters.filter.to_lowercase())
1183            })
1184            .collect()
1185    };
1186
1187    let filtered_count = filtered.len();
1188    let page_size = app.cfn_state.parameters.page_size.value();
1189    let total_pages = filtered_count.div_ceil(page_size);
1190    let current_page = if filtered_count > 0
1191        && app.cfn_state.parameters.scroll_offset + page_size >= filtered_count
1192    {
1193        total_pages.saturating_sub(1)
1194    } else {
1195        app.cfn_state.parameters.scroll_offset / page_size
1196    };
1197    let pagination = render_pagination_text(current_page, total_pages);
1198
1199    crate::ui::filter::render_filter_bar(
1200        frame,
1201        crate::ui::filter::FilterConfig {
1202            filter_text: &app.cfn_state.parameters.filter,
1203            placeholder: "Search parameters",
1204            mode: app.mode,
1205            is_input_focused: app.cfn_state.parameters_input_focus == InputFocus::Filter,
1206            controls: vec![crate::ui::filter::FilterControl {
1207                text: pagination,
1208                is_focused: app.cfn_state.parameters_input_focus == InputFocus::Pagination,
1209            }],
1210            area: chunks[0],
1211        },
1212    );
1213
1214    let page_start = app.cfn_state.parameters.scroll_offset;
1215    let page_end = (page_start + page_size).min(filtered_count);
1216    let page_params: Vec<_> = filtered[page_start..page_end].to_vec();
1217
1218    let columns: Vec<Box<dyn Column<StackParameter>>> = app
1219        .cfn_parameter_visible_column_ids
1220        .iter()
1221        .filter_map(|col_id| {
1222            ParameterColumn::from_id(col_id)
1223                .map(|col| Box::new(col) as Box<dyn Column<StackParameter>>)
1224        })
1225        .collect();
1226
1227    let expanded_index = app.cfn_state.parameters.expanded_item.and_then(|idx| {
1228        let scroll_offset = app.cfn_state.parameters.scroll_offset;
1229        if idx >= scroll_offset && idx < scroll_offset + page_size {
1230            Some(idx - scroll_offset)
1231        } else {
1232            None
1233        }
1234    });
1235
1236    let config = crate::ui::table::TableConfig {
1237        items: page_params,
1238        selected_index: app.cfn_state.parameters.selected % page_size,
1239        expanded_index,
1240        columns: &columns,
1241        sort_column: "Key",
1242        sort_direction: SortDirection::Asc,
1243        title: format!(" Parameters ({}) ", filtered_count),
1244        area: chunks[1],
1245        get_expanded_content: Some(Box::new(|param: &StackParameter| {
1246            crate::ui::table::expanded_from_columns(&columns, param)
1247        })),
1248        is_active: app.mode != Mode::FilterInput,
1249    };
1250
1251    crate::ui::table::render_table(frame, config);
1252}
1253
1254pub fn render_outputs(frame: &mut Frame, app: &App, area: Rect) {
1255    let chunks = Layout::default()
1256        .direction(Direction::Vertical)
1257        .constraints([Constraint::Length(3), Constraint::Min(0)])
1258        .split(area);
1259
1260    let filtered: Vec<&StackOutput> = if app.cfn_state.outputs.filter.is_empty() {
1261        app.cfn_state.outputs.items.iter().collect()
1262    } else {
1263        app.cfn_state
1264            .outputs
1265            .items
1266            .iter()
1267            .filter(|o| {
1268                o.key
1269                    .to_lowercase()
1270                    .contains(&app.cfn_state.outputs.filter.to_lowercase())
1271                    || o.value
1272                        .to_lowercase()
1273                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1274                    || o.description
1275                        .to_lowercase()
1276                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1277                    || o.export_name
1278                        .to_lowercase()
1279                        .contains(&app.cfn_state.outputs.filter.to_lowercase())
1280            })
1281            .collect()
1282    };
1283
1284    let filtered_count = filtered.len();
1285    let page_size = app.cfn_state.outputs.page_size.value();
1286    let total_pages = filtered_count.div_ceil(page_size);
1287    let current_page = if filtered_count > 0
1288        && app.cfn_state.outputs.scroll_offset + page_size >= filtered_count
1289    {
1290        total_pages.saturating_sub(1)
1291    } else {
1292        app.cfn_state.outputs.scroll_offset / page_size
1293    };
1294    let pagination = render_pagination_text(current_page, total_pages);
1295
1296    crate::ui::filter::render_filter_bar(
1297        frame,
1298        crate::ui::filter::FilterConfig {
1299            filter_text: &app.cfn_state.outputs.filter,
1300            placeholder: "Search outputs",
1301            mode: app.mode,
1302            is_input_focused: app.cfn_state.outputs_input_focus == InputFocus::Filter,
1303            controls: vec![crate::ui::filter::FilterControl {
1304                text: pagination,
1305                is_focused: app.cfn_state.outputs_input_focus == InputFocus::Pagination,
1306            }],
1307            area: chunks[0],
1308        },
1309    );
1310
1311    let page_start = app.cfn_state.outputs.scroll_offset;
1312    let page_end = (page_start + page_size).min(filtered_count);
1313    let page_outputs: Vec<_> = filtered[page_start..page_end].to_vec();
1314
1315    let columns: Vec<Box<dyn Column<StackOutput>>> = app
1316        .cfn_output_visible_column_ids
1317        .iter()
1318        .filter_map(|col_id| {
1319            OutputColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<StackOutput>>)
1320        })
1321        .collect();
1322
1323    let expanded_index = app.cfn_state.outputs.expanded_item.and_then(|idx| {
1324        let scroll_offset = app.cfn_state.outputs.scroll_offset;
1325        if idx >= scroll_offset && idx < scroll_offset + page_size {
1326            Some(idx - scroll_offset)
1327        } else {
1328            None
1329        }
1330    });
1331
1332    let config = crate::ui::table::TableConfig {
1333        items: page_outputs,
1334        selected_index: app.cfn_state.outputs.selected % page_size,
1335        expanded_index,
1336        columns: &columns,
1337        sort_column: "Key",
1338        sort_direction: SortDirection::Asc,
1339        title: format!(" Outputs ({}) ", filtered_count),
1340        area: chunks[1],
1341        get_expanded_content: Some(Box::new(|output: &StackOutput| {
1342            crate::ui::table::expanded_from_columns(&columns, output)
1343        })),
1344        is_active: app.mode != Mode::FilterInput,
1345    };
1346
1347    crate::ui::table::render_table(frame, config);
1348}
1349
1350pub fn render_resources(frame: &mut Frame, app: &App, area: Rect) {
1351    let chunks = Layout::default()
1352        .direction(Direction::Vertical)
1353        .constraints([Constraint::Length(3), Constraint::Min(0)])
1354        .split(area);
1355
1356    let filtered: Vec<&StackResource> = filtered_resources(app);
1357    let filtered_count = filtered.len();
1358    let page_size = app.cfn_state.resources.page_size.value();
1359    let total_pages = filtered_count.div_ceil(page_size);
1360    let current_page = if filtered_count > 0
1361        && app.cfn_state.resources.scroll_offset + page_size >= filtered_count
1362    {
1363        total_pages.saturating_sub(1)
1364    } else {
1365        app.cfn_state.resources.scroll_offset / page_size
1366    };
1367    let pagination = render_pagination_text(current_page, total_pages);
1368
1369    crate::ui::filter::render_filter_bar(
1370        frame,
1371        crate::ui::filter::FilterConfig {
1372            filter_text: &app.cfn_state.resources.filter,
1373            placeholder: "Search resources",
1374            mode: app.mode,
1375            is_input_focused: app.cfn_state.resources_input_focus == InputFocus::Filter,
1376            controls: vec![crate::ui::filter::FilterControl {
1377                text: pagination,
1378                is_focused: app.cfn_state.resources_input_focus == InputFocus::Pagination,
1379            }],
1380            area: chunks[0],
1381        },
1382    );
1383
1384    let page_start = app.cfn_state.resources.scroll_offset;
1385    let page_end = (page_start + page_size).min(filtered_count);
1386    let page_resources: Vec<_> = filtered[page_start..page_end].to_vec();
1387
1388    let columns: Vec<Box<dyn Column<StackResource>>> = app
1389        .cfn_resource_visible_column_ids
1390        .iter()
1391        .filter_map(|col_id| {
1392            ResourceColumn::from_id(col_id)
1393                .map(|col| Box::new(col) as Box<dyn Column<StackResource>>)
1394        })
1395        .collect();
1396
1397    let expanded_index = app.cfn_state.resources.expanded_item.and_then(|idx| {
1398        let scroll_offset = app.cfn_state.resources.scroll_offset;
1399        if idx >= scroll_offset && idx < scroll_offset + page_size {
1400            Some(idx - scroll_offset)
1401        } else {
1402            None
1403        }
1404    });
1405
1406    let config = crate::ui::table::TableConfig {
1407        items: page_resources,
1408        selected_index: app.cfn_state.resources.selected % page_size,
1409        expanded_index,
1410        columns: &columns,
1411        sort_column: "Logical ID",
1412        sort_direction: SortDirection::Asc,
1413        title: format!(" Resources ({}) ", filtered_count),
1414        area: chunks[1],
1415        get_expanded_content: Some(Box::new(|resource: &StackResource| {
1416            crate::ui::table::expanded_from_columns(&columns, resource)
1417        })),
1418        is_active: app.mode != Mode::FilterInput,
1419    };
1420
1421    crate::ui::table::render_table(frame, config);
1422}
1423
1424#[derive(Debug, Clone, Copy, PartialEq)]
1425pub enum ParameterColumn {
1426    Key,
1427    Value,
1428    ResolvedValue,
1429}
1430
1431impl ParameterColumn {
1432    fn id(&self) -> &'static str {
1433        match self {
1434            ParameterColumn::Key => "cfn.parameter.key",
1435            ParameterColumn::Value => "cfn.parameter.value",
1436            ParameterColumn::ResolvedValue => "cfn.parameter.resolved_value",
1437        }
1438    }
1439
1440    pub fn default_name(&self) -> &'static str {
1441        match self {
1442            ParameterColumn::Key => "Key",
1443            ParameterColumn::Value => "Value",
1444            ParameterColumn::ResolvedValue => "Resolved value",
1445        }
1446    }
1447
1448    pub fn all() -> Vec<Self> {
1449        vec![Self::Key, Self::Value, Self::ResolvedValue]
1450    }
1451
1452    pub fn from_id(id: &str) -> Option<Self> {
1453        match id {
1454            "cfn.parameter.key" => Some(Self::Key),
1455            "cfn.parameter.value" => Some(Self::Value),
1456            "cfn.parameter.resolved_value" => Some(Self::ResolvedValue),
1457            _ => None,
1458        }
1459    }
1460}
1461
1462impl Column<StackParameter> for ParameterColumn {
1463    fn id(&self) -> &'static str {
1464        Self::id(self)
1465    }
1466
1467    fn default_name(&self) -> &'static str {
1468        Self::default_name(self)
1469    }
1470
1471    fn width(&self) -> u16 {
1472        let translated = translate_column(self.id(), self.default_name());
1473        translated.len().max(match self {
1474            ParameterColumn::Key => 40,
1475            ParameterColumn::Value => 50,
1476            ParameterColumn::ResolvedValue => 50,
1477        }) as u16
1478    }
1479
1480    fn render(&self, item: &StackParameter) -> (String, Style) {
1481        match self {
1482            ParameterColumn::Key => (item.key.clone(), Style::default()),
1483            ParameterColumn::Value => (item.value.clone(), Style::default()),
1484            ParameterColumn::ResolvedValue => (item.resolved_value.clone(), Style::default()),
1485        }
1486    }
1487}
1488
1489#[derive(Debug, Clone, Copy, PartialEq)]
1490pub enum OutputColumn {
1491    Key,
1492    Value,
1493    Description,
1494    ExportName,
1495}
1496
1497impl OutputColumn {
1498    fn id(&self) -> &'static str {
1499        match self {
1500            OutputColumn::Key => "cfn.output.key",
1501            OutputColumn::Value => "cfn.output.value",
1502            OutputColumn::Description => "cfn.output.description",
1503            OutputColumn::ExportName => "cfn.output.export_name",
1504        }
1505    }
1506
1507    pub fn default_name(&self) -> &'static str {
1508        match self {
1509            OutputColumn::Key => "Key",
1510            OutputColumn::Value => "Value",
1511            OutputColumn::Description => "Description",
1512            OutputColumn::ExportName => "Export name",
1513        }
1514    }
1515
1516    pub fn all() -> Vec<Self> {
1517        vec![Self::Key, Self::Value, Self::Description, Self::ExportName]
1518    }
1519
1520    pub fn from_id(id: &str) -> Option<Self> {
1521        match id {
1522            "cfn.output.key" => Some(Self::Key),
1523            "cfn.output.value" => Some(Self::Value),
1524            "cfn.output.description" => Some(Self::Description),
1525            "cfn.output.export_name" => Some(Self::ExportName),
1526            _ => None,
1527        }
1528    }
1529}
1530
1531impl Column<StackOutput> for OutputColumn {
1532    fn id(&self) -> &'static str {
1533        Self::id(self)
1534    }
1535
1536    fn default_name(&self) -> &'static str {
1537        Self::default_name(self)
1538    }
1539
1540    fn width(&self) -> u16 {
1541        let translated = translate_column(self.id(), self.default_name());
1542        translated.len().max(match self {
1543            OutputColumn::Key => 40,
1544            OutputColumn::Value => 50,
1545            OutputColumn::Description => 50,
1546            OutputColumn::ExportName => 40,
1547        }) as u16
1548    }
1549
1550    fn render(&self, item: &StackOutput) -> (String, Style) {
1551        match self {
1552            OutputColumn::Key => (item.key.clone(), Style::default()),
1553            OutputColumn::Value => (item.value.clone(), Style::default()),
1554            OutputColumn::Description => (item.description.clone(), Style::default()),
1555            OutputColumn::ExportName => (item.export_name.clone(), Style::default()),
1556        }
1557    }
1558}
1559
1560impl Column<StackResource> for ResourceColumn {
1561    fn id(&self) -> &'static str {
1562        Self::id(self)
1563    }
1564
1565    fn default_name(&self) -> &'static str {
1566        Self::default_name(self)
1567    }
1568
1569    fn width(&self) -> u16 {
1570        let translated = translate_column(self.id(), self.default_name());
1571        translated.len().max(match self {
1572            ResourceColumn::LogicalId => 40,
1573            ResourceColumn::PhysicalId => 50,
1574            ResourceColumn::Type => 40,
1575            ResourceColumn::Status => 25,
1576            ResourceColumn::Module => 40,
1577        }) as u16
1578    }
1579
1580    fn render(&self, item: &StackResource) -> (String, Style) {
1581        match self {
1582            ResourceColumn::LogicalId => (item.logical_id.clone(), Style::default()),
1583            ResourceColumn::PhysicalId => (item.physical_id.clone(), Style::default()),
1584            ResourceColumn::Type => (item.resource_type.clone(), Style::default()),
1585            ResourceColumn::Status => (item.status.clone(), Style::default()),
1586            ResourceColumn::Module => (item.module_info.clone(), Style::default()),
1587        }
1588    }
1589}
1590
1591#[derive(Debug, Clone, Copy, PartialEq)]
1592enum TagColumn {
1593    Key,
1594    Value,
1595}
1596
1597impl Column<(String, String)> for TagColumn {
1598    fn name(&self) -> &str {
1599        match self {
1600            TagColumn::Key => "Key",
1601            TagColumn::Value => "Value",
1602        }
1603    }
1604
1605    fn width(&self) -> u16 {
1606        match self {
1607            TagColumn::Key => 40,
1608            TagColumn::Value => 60,
1609        }
1610    }
1611
1612    fn render(&self, item: &(String, String)) -> (String, Style) {
1613        match self {
1614            TagColumn::Key => (item.0.clone(), Style::default()),
1615            TagColumn::Value => (item.1.clone(), Style::default()),
1616        }
1617    }
1618}
1619
1620#[cfg(test)]
1621mod parameter_tests {
1622    use super::*;
1623    use crate::app::App;
1624
1625    fn test_app() -> App {
1626        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1627    }
1628
1629    #[test]
1630    fn test_filtered_parameters_empty_filter() {
1631        let mut app = test_app();
1632        app.cfn_state.parameters.items = vec![
1633            StackParameter {
1634                key: "Param1".to_string(),
1635                value: "Value1".to_string(),
1636                resolved_value: "Resolved1".to_string(),
1637            },
1638            StackParameter {
1639                key: "Param2".to_string(),
1640                value: "Value2".to_string(),
1641                resolved_value: "Resolved2".to_string(),
1642            },
1643        ];
1644        app.cfn_state.parameters.filter = String::new();
1645
1646        let filtered = filtered_parameters(&app);
1647        assert_eq!(filtered.len(), 2);
1648    }
1649
1650    #[test]
1651    fn test_filtered_parameters_by_key() {
1652        let mut app = test_app();
1653        app.cfn_state.parameters.items = vec![
1654            StackParameter {
1655                key: "DatabaseName".to_string(),
1656                value: "mydb".to_string(),
1657                resolved_value: "mydb".to_string(),
1658            },
1659            StackParameter {
1660                key: "InstanceType".to_string(),
1661                value: "t2.micro".to_string(),
1662                resolved_value: "t2.micro".to_string(),
1663            },
1664        ];
1665        app.cfn_state.parameters.filter = "database".to_string();
1666
1667        let filtered = filtered_parameters(&app);
1668        assert_eq!(filtered.len(), 1);
1669        assert_eq!(filtered[0].key, "DatabaseName");
1670    }
1671
1672    #[test]
1673    fn test_filtered_parameters_by_value() {
1674        let mut app = test_app();
1675        app.cfn_state.parameters.items = vec![
1676            StackParameter {
1677                key: "Param1".to_string(),
1678                value: "production".to_string(),
1679                resolved_value: "production".to_string(),
1680            },
1681            StackParameter {
1682                key: "Param2".to_string(),
1683                value: "staging".to_string(),
1684                resolved_value: "staging".to_string(),
1685            },
1686        ];
1687        app.cfn_state.parameters.filter = "prod".to_string();
1688
1689        let filtered = filtered_parameters(&app);
1690        assert_eq!(filtered.len(), 1);
1691        assert_eq!(filtered[0].value, "production");
1692    }
1693
1694    #[test]
1695    fn test_parameters_state_initialization() {
1696        let state = State::new();
1697        assert_eq!(state.parameters.items.len(), 0);
1698        assert_eq!(state.parameters.selected, 0);
1699        assert_eq!(state.parameters.filter, "");
1700        assert_eq!(state.parameters_input_focus, InputFocus::Filter);
1701    }
1702
1703    #[test]
1704    fn test_parameters_expansion() {
1705        let mut app = test_app();
1706        app.current_service = crate::app::Service::CloudFormationStacks;
1707        app.cfn_state.current_stack = Some("test-stack".to_string());
1708        app.cfn_state.detail_tab = DetailTab::Parameters;
1709        app.cfn_state.parameters.items = vec![StackParameter {
1710            key: "Param1".to_string(),
1711            value: "Value1".to_string(),
1712            resolved_value: "Resolved1".to_string(),
1713        }];
1714
1715        assert_eq!(app.cfn_state.parameters.expanded_item, None);
1716
1717        // Expand
1718        app.cfn_state.parameters.toggle_expand();
1719        assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
1720
1721        // Collapse
1722        app.cfn_state.parameters.collapse();
1723        assert_eq!(app.cfn_state.parameters.expanded_item, None);
1724    }
1725}
1726
1727#[cfg(test)]
1728mod output_tests {
1729    use super::*;
1730    use crate::app::App;
1731
1732    fn test_app() -> App {
1733        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1734    }
1735
1736    #[test]
1737    fn test_filtered_outputs_empty_filter() {
1738        let mut app = test_app();
1739        app.cfn_state.outputs.items = vec![
1740            StackOutput {
1741                key: "Output1".to_string(),
1742                value: "Value1".to_string(),
1743                description: "Desc1".to_string(),
1744                export_name: "Export1".to_string(),
1745            },
1746            StackOutput {
1747                key: "Output2".to_string(),
1748                value: "Value2".to_string(),
1749                description: "Desc2".to_string(),
1750                export_name: "Export2".to_string(),
1751            },
1752        ];
1753        app.cfn_state.outputs.filter = String::new();
1754
1755        let filtered = filtered_outputs(&app);
1756        assert_eq!(filtered.len(), 2);
1757    }
1758
1759    #[test]
1760    fn test_filtered_outputs_by_key() {
1761        let mut app = test_app();
1762        app.cfn_state.outputs.items = vec![
1763            StackOutput {
1764                key: "ApiUrl".to_string(),
1765                value: "https://api.example.com".to_string(),
1766                description: "API endpoint".to_string(),
1767                export_name: "MyApiUrl".to_string(),
1768            },
1769            StackOutput {
1770                key: "BucketName".to_string(),
1771                value: "my-bucket".to_string(),
1772                description: "S3 bucket".to_string(),
1773                export_name: "MyBucket".to_string(),
1774            },
1775        ];
1776        app.cfn_state.outputs.filter = "api".to_string();
1777
1778        let filtered = filtered_outputs(&app);
1779        assert_eq!(filtered.len(), 1);
1780        assert_eq!(filtered[0].key, "ApiUrl");
1781    }
1782
1783    #[test]
1784    fn test_filtered_outputs_by_value() {
1785        let mut app = test_app();
1786        app.cfn_state.outputs.items = vec![
1787            StackOutput {
1788                key: "ApiUrl".to_string(),
1789                value: "https://api.example.com".to_string(),
1790                description: "API endpoint".to_string(),
1791                export_name: "MyApiUrl".to_string(),
1792            },
1793            StackOutput {
1794                key: "BucketName".to_string(),
1795                value: "my-bucket".to_string(),
1796                description: "S3 bucket".to_string(),
1797                export_name: "MyBucket".to_string(),
1798            },
1799        ];
1800        app.cfn_state.outputs.filter = "my-bucket".to_string();
1801
1802        let filtered = filtered_outputs(&app);
1803        assert_eq!(filtered.len(), 1);
1804        assert_eq!(filtered[0].key, "BucketName");
1805    }
1806
1807    #[test]
1808    fn test_outputs_state_initialization() {
1809        let app = test_app();
1810        assert_eq!(app.cfn_state.outputs.items.len(), 0);
1811        assert_eq!(app.cfn_state.outputs.filter, "");
1812        assert_eq!(app.cfn_state.outputs.selected, 0);
1813        assert_eq!(app.cfn_state.outputs.expanded_item, None);
1814    }
1815
1816    #[test]
1817    fn test_outputs_expansion() {
1818        let mut app = test_app();
1819        app.cfn_state.current_stack = Some("test-stack".to_string());
1820        app.cfn_state.detail_tab = DetailTab::Outputs;
1821        app.cfn_state.outputs.items = vec![StackOutput {
1822            key: "Output1".to_string(),
1823            value: "Value1".to_string(),
1824            description: "Desc1".to_string(),
1825            export_name: "Export1".to_string(),
1826        }];
1827
1828        assert_eq!(app.cfn_state.outputs.expanded_item, None);
1829
1830        // Expand
1831        app.cfn_state.outputs.toggle_expand();
1832        assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
1833
1834        // Collapse
1835        app.cfn_state.outputs.collapse();
1836        assert_eq!(app.cfn_state.outputs.expanded_item, None);
1837    }
1838
1839    #[test]
1840    fn test_expanded_items_hierarchical_view() {
1841        let mut app = test_app();
1842
1843        // Initially empty
1844        assert!(app.cfn_state.expanded_items.is_empty());
1845
1846        // Expand a resource
1847        app.cfn_state
1848            .expanded_items
1849            .insert("MyNestedStack".to_string());
1850        assert!(app.cfn_state.expanded_items.contains("MyNestedStack"));
1851
1852        // Expand another resource
1853        app.cfn_state
1854            .expanded_items
1855            .insert("MyNestedStack/ChildResource".to_string());
1856        assert_eq!(app.cfn_state.expanded_items.len(), 2);
1857
1858        // Collapse a resource
1859        app.cfn_state.expanded_items.remove("MyNestedStack");
1860        assert!(!app.cfn_state.expanded_items.contains("MyNestedStack"));
1861        assert_eq!(app.cfn_state.expanded_items.len(), 1);
1862    }
1863}
1864
1865#[cfg(test)]
1866mod resource_tests {
1867    use super::*;
1868    use crate::app::App;
1869
1870    fn test_app() -> App {
1871        App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1872    }
1873
1874    #[test]
1875    fn test_resources_state_initialization() {
1876        let app = test_app();
1877        assert_eq!(app.cfn_state.resources.items.len(), 0);
1878        assert_eq!(app.cfn_state.resources.filter, "");
1879        assert_eq!(app.cfn_state.resources.selected, 0);
1880    }
1881
1882    #[test]
1883    fn test_filtered_resources_empty_filter() {
1884        let mut app = test_app();
1885        app.cfn_state.resources.items = vec![
1886            StackResource {
1887                logical_id: "MyBucket".to_string(),
1888                physical_id: "my-bucket-123".to_string(),
1889                resource_type: "AWS::S3::Bucket".to_string(),
1890                status: "CREATE_COMPLETE".to_string(),
1891                module_info: String::new(),
1892            },
1893            StackResource {
1894                logical_id: "MyFunction".to_string(),
1895                physical_id: "my-function-456".to_string(),
1896                resource_type: "AWS::Lambda::Function".to_string(),
1897                status: "CREATE_COMPLETE".to_string(),
1898                module_info: String::new(),
1899            },
1900        ];
1901        app.cfn_state.resources.filter = String::new();
1902
1903        let filtered: Vec<&StackResource> = if app.cfn_state.resources.filter.is_empty() {
1904            app.cfn_state.resources.items.iter().collect()
1905        } else {
1906            app.cfn_state
1907                .resources
1908                .items
1909                .iter()
1910                .filter(|r| {
1911                    r.logical_id
1912                        .to_lowercase()
1913                        .contains(&app.cfn_state.resources.filter.to_lowercase())
1914                })
1915                .collect()
1916        };
1917        assert_eq!(filtered.len(), 2);
1918    }
1919
1920    #[test]
1921    fn test_filtered_resources_by_logical_id() {
1922        let mut app = test_app();
1923        app.cfn_state.resources.items = vec![
1924            StackResource {
1925                logical_id: "MyBucket".to_string(),
1926                physical_id: "my-bucket-123".to_string(),
1927                resource_type: "AWS::S3::Bucket".to_string(),
1928                status: "CREATE_COMPLETE".to_string(),
1929                module_info: String::new(),
1930            },
1931            StackResource {
1932                logical_id: "MyFunction".to_string(),
1933                physical_id: "my-function-456".to_string(),
1934                resource_type: "AWS::Lambda::Function".to_string(),
1935                status: "CREATE_COMPLETE".to_string(),
1936                module_info: String::new(),
1937            },
1938        ];
1939        app.cfn_state.resources.filter = "bucket".to_string();
1940
1941        let filtered: Vec<&StackResource> = app
1942            .cfn_state
1943            .resources
1944            .items
1945            .iter()
1946            .filter(|r| {
1947                r.logical_id
1948                    .to_lowercase()
1949                    .contains(&app.cfn_state.resources.filter.to_lowercase())
1950            })
1951            .collect();
1952        assert_eq!(filtered.len(), 1);
1953        assert_eq!(filtered[0].logical_id, "MyBucket");
1954    }
1955
1956    #[test]
1957    fn test_filtered_resources_by_type() {
1958        let mut app = test_app();
1959        app.cfn_state.resources.items = vec![
1960            StackResource {
1961                logical_id: "MyBucket".to_string(),
1962                physical_id: "my-bucket-123".to_string(),
1963                resource_type: "AWS::S3::Bucket".to_string(),
1964                status: "CREATE_COMPLETE".to_string(),
1965                module_info: String::new(),
1966            },
1967            StackResource {
1968                logical_id: "MyFunction".to_string(),
1969                physical_id: "my-function-456".to_string(),
1970                resource_type: "AWS::Lambda::Function".to_string(),
1971                status: "CREATE_COMPLETE".to_string(),
1972                module_info: String::new(),
1973            },
1974        ];
1975        app.cfn_state.resources.filter = "lambda".to_string();
1976
1977        let filtered: Vec<&StackResource> = app
1978            .cfn_state
1979            .resources
1980            .items
1981            .iter()
1982            .filter(|r| {
1983                r.resource_type
1984                    .to_lowercase()
1985                    .contains(&app.cfn_state.resources.filter.to_lowercase())
1986            })
1987            .collect();
1988        assert_eq!(filtered.len(), 1);
1989        assert_eq!(filtered[0].logical_id, "MyFunction");
1990    }
1991
1992    #[test]
1993    fn test_resources_sorted_by_logical_id() {
1994        let mut app = test_app();
1995        app.cfn_state.resources.items = vec![
1996            StackResource {
1997                logical_id: "ZBucket".to_string(),
1998                physical_id: "z-bucket".to_string(),
1999                resource_type: "AWS::S3::Bucket".to_string(),
2000                status: "CREATE_COMPLETE".to_string(),
2001                module_info: String::new(),
2002            },
2003            StackResource {
2004                logical_id: "AFunction".to_string(),
2005                physical_id: "a-function".to_string(),
2006                resource_type: "AWS::Lambda::Function".to_string(),
2007                status: "CREATE_COMPLETE".to_string(),
2008                module_info: String::new(),
2009            },
2010        ];
2011
2012        // Resources should be sorted by logical_id in the API response
2013        // but let's verify the order
2014        assert_eq!(app.cfn_state.resources.items[0].logical_id, "ZBucket");
2015        assert_eq!(app.cfn_state.resources.items[1].logical_id, "AFunction");
2016    }
2017
2018    #[test]
2019    fn test_resources_expansion() {
2020        let mut app = test_app();
2021        app.cfn_state.resources.items = vec![StackResource {
2022            logical_id: "MyBucket".to_string(),
2023            physical_id: "my-bucket-123".to_string(),
2024            resource_type: "AWS::S3::Bucket".to_string(),
2025            status: "CREATE_COMPLETE".to_string(),
2026            module_info: String::new(),
2027        }];
2028
2029        assert_eq!(app.cfn_state.resources.expanded_item, None);
2030
2031        // Expand
2032        app.cfn_state.resources.toggle_expand();
2033        assert_eq!(app.cfn_state.resources.expanded_item, Some(0));
2034
2035        // Collapse
2036        app.cfn_state.resources.collapse();
2037        assert_eq!(app.cfn_state.resources.expanded_item, None);
2038    }
2039
2040    #[test]
2041    fn test_resource_column_ids() {
2042        let ids = resource_column_ids();
2043        assert_eq!(ids.len(), 5);
2044        assert!(ids.contains(&"cfn.resource.logical_id"));
2045        assert!(ids.contains(&"cfn.resource.physical_id"));
2046        assert!(ids.contains(&"cfn.resource.type"));
2047        assert!(ids.contains(&"cfn.resource.status"));
2048        assert!(ids.contains(&"cfn.resource.module"));
2049    }
2050
2051    #[test]
2052    fn test_detail_tab_allows_preferences() {
2053        assert!(DetailTab::StackInfo.allows_preferences());
2054        assert!(DetailTab::Parameters.allows_preferences());
2055        assert!(DetailTab::Outputs.allows_preferences());
2056        assert!(DetailTab::Resources.allows_preferences());
2057        assert!(!DetailTab::Template.allows_preferences());
2058        assert!(!DetailTab::Events.allows_preferences());
2059        assert!(!DetailTab::ChangeSets.allows_preferences());
2060        assert!(!DetailTab::GitSync.allows_preferences());
2061    }
2062
2063    #[test]
2064    fn test_resources_tree_view_expansion() {
2065        let mut app = test_app();
2066        app.cfn_state.resources.items = vec![
2067            StackResource {
2068                logical_id: "ParentModule".to_string(),
2069                physical_id: "parent-123".to_string(),
2070                resource_type: "AWS::CloudFormation::Stack".to_string(),
2071                status: "CREATE_COMPLETE".to_string(),
2072                module_info: String::new(),
2073            },
2074            StackResource {
2075                logical_id: "ChildResource".to_string(),
2076                physical_id: "child-456".to_string(),
2077                resource_type: "AWS::S3::Bucket".to_string(),
2078                status: "CREATE_COMPLETE".to_string(),
2079                module_info: "ParentModule".to_string(),
2080            },
2081        ];
2082
2083        // Initially not expanded
2084        assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2085
2086        // Expand parent
2087        app.cfn_state
2088            .expanded_items
2089            .insert("ParentModule".to_string());
2090        assert!(app.cfn_state.expanded_items.contains("ParentModule"));
2091
2092        // Collapse parent
2093        app.cfn_state.expanded_items.remove("ParentModule");
2094        assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2095    }
2096
2097    #[test]
2098    fn test_resources_navigation() {
2099        let mut app = test_app();
2100        app.current_service = crate::app::Service::CloudFormationStacks;
2101        app.cfn_state.current_stack = Some("test-stack".to_string());
2102        app.cfn_state.detail_tab = DetailTab::Resources;
2103        app.cfn_state.resources.items = vec![
2104            StackResource {
2105                logical_id: "Resource1".to_string(),
2106                physical_id: "res-1".to_string(),
2107                resource_type: "AWS::S3::Bucket".to_string(),
2108                status: "CREATE_COMPLETE".to_string(),
2109                module_info: String::new(),
2110            },
2111            StackResource {
2112                logical_id: "Resource2".to_string(),
2113                physical_id: "res-2".to_string(),
2114                resource_type: "AWS::Lambda::Function".to_string(),
2115                status: "CREATE_COMPLETE".to_string(),
2116                module_info: String::new(),
2117            },
2118        ];
2119
2120        assert_eq!(app.cfn_state.resources.selected, 0);
2121
2122        // Navigate down
2123        let filtered = filtered_resources(&app);
2124        app.cfn_state.resources.next_item(filtered.len());
2125        assert_eq!(app.cfn_state.resources.selected, 1);
2126
2127        // Navigate up
2128        app.cfn_state.resources.prev_item();
2129        assert_eq!(app.cfn_state.resources.selected, 0);
2130    }
2131
2132    #[test]
2133    fn test_resources_filter() {
2134        let mut app = test_app();
2135        app.cfn_state.resources.items = vec![
2136            StackResource {
2137                logical_id: "MyBucket".to_string(),
2138                physical_id: "my-bucket-123".to_string(),
2139                resource_type: "AWS::S3::Bucket".to_string(),
2140                status: "CREATE_COMPLETE".to_string(),
2141                module_info: String::new(),
2142            },
2143            StackResource {
2144                logical_id: "MyFunction".to_string(),
2145                physical_id: "my-function-456".to_string(),
2146                resource_type: "AWS::Lambda::Function".to_string(),
2147                status: "CREATE_COMPLETE".to_string(),
2148                module_info: String::new(),
2149            },
2150        ];
2151
2152        // No filter
2153        app.cfn_state.resources.filter = String::new();
2154        let filtered = filtered_resources(&app);
2155        assert_eq!(filtered.len(), 2);
2156
2157        // Filter by logical ID
2158        app.cfn_state.resources.filter = "bucket".to_string();
2159        let filtered = filtered_resources(&app);
2160        assert_eq!(filtered.len(), 1);
2161        assert_eq!(filtered[0].logical_id, "MyBucket");
2162
2163        // Filter by type
2164        app.cfn_state.resources.filter = "lambda".to_string();
2165        let filtered = filtered_resources(&app);
2166        assert_eq!(filtered.len(), 1);
2167        assert_eq!(filtered[0].logical_id, "MyFunction");
2168    }
2169
2170    #[test]
2171    fn test_resources_page_size() {
2172        use crate::common::PageSize;
2173        let mut app = test_app();
2174
2175        // Default page size
2176        assert_eq!(app.cfn_state.resources.page_size, PageSize::Fifty);
2177        assert_eq!(app.cfn_state.resources.page_size.value(), 50);
2178
2179        // Change page size
2180        app.cfn_state.resources.page_size = PageSize::TwentyFive;
2181        assert_eq!(app.cfn_state.resources.page_size, PageSize::TwentyFive);
2182        assert_eq!(app.cfn_state.resources.page_size.value(), 25);
2183    }
2184}