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