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