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 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), Constraint::Min(0), ])
356 .split(area);
357
358 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 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 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 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 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), Constraint::Min(0), ])
493 .split(area);
494
495 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 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 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 let tags_height = 12; let policy_height = 15; 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 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; 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), ])
708 .split(area);
709
710 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(frame, app, sections[1]);
723
724 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 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 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 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 let field_count = 8;
927 let expected_height = field_count + 2; 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 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 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 app.cfn_state.tags.filter = String::new();
1053 let filtered = filtered_tags(&app);
1054 assert_eq!(filtered.len(), 3);
1055
1056 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 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 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 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 app.cfn_state.parameters.toggle_expand();
1675 assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
1676
1677 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 app.cfn_state.outputs.toggle_expand();
1788 assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
1789
1790 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 assert!(app.cfn_state.expanded_items.is_empty());
1801
1802 app.cfn_state
1804 .expanded_items
1805 .insert("MyNestedStack".to_string());
1806 assert!(app.cfn_state.expanded_items.contains("MyNestedStack"));
1807
1808 app.cfn_state
1810 .expanded_items
1811 .insert("MyNestedStack/ChildResource".to_string());
1812 assert_eq!(app.cfn_state.expanded_items.len(), 2);
1813
1814 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 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 app.cfn_state.resources.toggle_expand();
1989 assert_eq!(app.cfn_state.resources.expanded_item, Some(0));
1990
1991 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 assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
2041
2042 app.cfn_state
2044 .expanded_items
2045 .insert("ParentModule".to_string());
2046 assert!(app.cfn_state.expanded_items.contains("ParentModule"));
2047
2048 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 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 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 app.cfn_state.resources.filter = String::new();
2111 let filtered = filtered_resources(&app);
2112 assert_eq!(filtered.len(), 2);
2113
2114 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 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 assert_eq!(app.cfn_state.resources.page_size, PageSize::Fifty);
2134 assert_eq!(app.cfn_state.resources.page_size.value(), 50);
2135
2136 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}