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