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 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), Constraint::Min(0), ])
421 .split(area);
422
423 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 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 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 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 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), Constraint::Length(1), Constraint::Min(0), ])
559 .split(area);
560
561 frame.render_widget(Paragraph::new(stack.name.clone()), chunks[0]);
563
564 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 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 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; let tags_height = 12; let policy_height = 15; 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 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; 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), ])
770 .split(area);
771
772 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(frame, app, sections[1]);
784
785 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 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 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 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 let field_count = 8;
998 let expected_height = field_count + 2; 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 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 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 app.cfn_state.tags.filter = String::new();
1124 let filtered = filtered_tags(&app);
1125 assert_eq!(filtered.len(), 3);
1126
1127 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 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 app.cfn_state.parameters.toggle_expand();
1719 assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
1720
1721 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 app.cfn_state.outputs.toggle_expand();
1832 assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
1833
1834 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 assert!(app.cfn_state.expanded_items.is_empty());
1845
1846 app.cfn_state
1848 .expanded_items
1849 .insert("MyNestedStack".to_string());
1850 assert!(app.cfn_state.expanded_items.contains("MyNestedStack"));
1851
1852 app.cfn_state
1854 .expanded_items
1855 .insert("MyNestedStack/ChildResource".to_string());
1856 assert_eq!(app.cfn_state.expanded_items.len(), 2);
1857
1858 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 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 app.cfn_state.resources.toggle_expand();
2033 assert_eq!(app.cfn_state.resources.expanded_item, Some(0));
2034
2035 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 assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2085
2086 app.cfn_state
2088 .expanded_items
2089 .insert("ParentModule".to_string());
2090 assert!(app.cfn_state.expanded_items.contains("ParentModule"));
2091
2092 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 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 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 app.cfn_state.resources.filter = String::new();
2154 let filtered = filtered_resources(&app);
2155 assert_eq!(filtered.len(), 2);
2156
2157 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 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 assert_eq!(app.cfn_state.resources.page_size, PageSize::Fifty);
2177 assert_eq!(app.cfn_state.resources.page_size.value(), 50);
2178
2179 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}