use crate::app::App;
use crate::cfn::{format_status, Column as CfnColumn, Stack as CfnStack};
use crate::common::{
filter_by_fields, format_iso_timestamp, render_dropdown, render_pagination_text,
translate_column, ColumnId, CyclicEnum, InputFocus, SortDirection, UTC_TIMESTAMP_WIDTH,
};
use crate::keymap::Mode;
use crate::table::TableState;
use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
use crate::ui::{
calculate_dynamic_height, format_title, labeled_field, render_fields_with_dynamic_columns,
render_json_highlighted, render_tabs, render_template_highlighted, titled_block,
};
use ratatui::{prelude::*, widgets::*};
use rusticity_core::cfn::{StackChangeSet, StackEvent, StackOutput, StackParameter, StackResource};
use std::collections::HashSet;
pub const STATUS_FILTER: InputFocus = InputFocus::Dropdown("StatusFilter");
pub const VIEW_NESTED: InputFocus = InputFocus::Checkbox("ViewNested");
impl State {
pub const FILTER_CONTROLS: [InputFocus; 4] = [
InputFocus::Filter,
STATUS_FILTER,
VIEW_NESTED,
InputFocus::Pagination,
];
pub const PARAMETERS_FILTER_CONTROLS: [InputFocus; 2] =
[InputFocus::Filter, InputFocus::Pagination];
pub const OUTPUTS_FILTER_CONTROLS: [InputFocus; 2] =
[InputFocus::Filter, InputFocus::Pagination];
pub const RESOURCES_FILTER_CONTROLS: [InputFocus; 2] =
[InputFocus::Filter, InputFocus::Pagination];
pub const EVENTS_FILTER_CONTROLS: [InputFocus; 2] =
[InputFocus::Filter, InputFocus::Pagination];
pub const CHANGE_SETS_FILTER_CONTROLS: [InputFocus; 2] =
[InputFocus::Filter, InputFocus::Pagination];
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum EventsView {
#[default]
Table,
Timeline,
}
pub struct State {
pub table: TableState<CfnStack>,
pub input_focus: InputFocus,
pub status_filter: StatusFilter,
pub view_nested: bool,
pub current_stack: Option<String>,
pub detail_tab: DetailTab,
pub overview_scroll: u16,
pub sort_column: CfnColumn,
pub sort_direction: SortDirection,
pub template_body: String,
pub template_scroll: usize,
pub parameters: TableState<StackParameter>,
pub parameters_input_focus: InputFocus,
pub outputs: TableState<StackOutput>,
pub outputs_input_focus: InputFocus,
pub tags: TableState<(String, String)>,
pub policy_scroll: usize,
pub expanded_items: HashSet<String>,
pub resources: TableState<StackResource>,
pub resources_input_focus: InputFocus,
pub events: TableState<StackEvent>,
pub events_input_focus: InputFocus,
pub events_view: EventsView,
pub change_sets: TableState<StackChangeSet>,
pub change_sets_input_focus: InputFocus,
}
impl Default for State {
fn default() -> Self {
Self::new()
}
}
impl State {
pub fn new() -> Self {
Self {
table: TableState::new(),
input_focus: InputFocus::Filter,
status_filter: StatusFilter::All,
view_nested: false,
current_stack: None,
detail_tab: DetailTab::StackInfo,
overview_scroll: 0,
sort_column: CfnColumn::CreatedTime,
sort_direction: SortDirection::Desc,
template_body: String::new(),
template_scroll: 0,
parameters: TableState::new(),
parameters_input_focus: InputFocus::Filter,
outputs: TableState::new(),
outputs_input_focus: InputFocus::Filter,
tags: TableState::new(),
policy_scroll: 0,
expanded_items: HashSet::new(),
resources: TableState::new(),
resources_input_focus: InputFocus::Filter,
events: TableState::new(),
events_input_focus: InputFocus::Filter,
events_view: EventsView::Table,
change_sets: TableState::new(),
change_sets_input_focus: InputFocus::Filter,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StatusFilter {
All,
Active,
Complete,
Failed,
Deleted,
InProgress,
}
impl StatusFilter {
pub fn name(&self) -> &'static str {
match self {
StatusFilter::All => "All",
StatusFilter::Active => "Active",
StatusFilter::Complete => "Complete",
StatusFilter::Failed => "Failed",
StatusFilter::Deleted => "Deleted",
StatusFilter::InProgress => "In progress",
}
}
pub fn all() -> Vec<StatusFilter> {
vec![
StatusFilter::All,
StatusFilter::Active,
StatusFilter::Complete,
StatusFilter::Failed,
StatusFilter::Deleted,
StatusFilter::InProgress,
]
}
}
impl CyclicEnum for StatusFilter {
const ALL: &'static [Self] = &[
Self::All,
Self::Active,
Self::Complete,
Self::Failed,
Self::Deleted,
Self::InProgress,
];
}
impl StatusFilter {
pub fn matches(&self, status: &str) -> bool {
match self {
StatusFilter::All => true,
StatusFilter::Active => {
!status.contains("DELETE")
&& !status.contains("COMPLETE")
&& !status.contains("FAILED")
}
StatusFilter::Complete => status.contains("COMPLETE") && !status.contains("DELETE"),
StatusFilter::Failed => status.contains("FAILED"),
StatusFilter::Deleted => status.contains("DELETE"),
StatusFilter::InProgress => status.contains("IN_PROGRESS"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DetailTab {
StackInfo,
Events,
Resources,
Outputs,
Parameters,
Template,
ChangeSets,
GitSync,
}
impl CyclicEnum for DetailTab {
const ALL: &'static [Self] = &[
Self::StackInfo,
Self::Events,
Self::Resources,
Self::Outputs,
Self::Parameters,
Self::Template,
Self::ChangeSets,
Self::GitSync,
];
}
impl DetailTab {
pub fn name(&self) -> &'static str {
match self {
DetailTab::StackInfo => "Stack info",
DetailTab::Events => "Events",
DetailTab::Resources => "Resources",
DetailTab::Outputs => "Outputs",
DetailTab::Parameters => "Parameters",
DetailTab::Template => "Template",
DetailTab::ChangeSets => "Change sets",
DetailTab::GitSync => "Git sync",
}
}
pub fn all() -> Vec<DetailTab> {
vec![
DetailTab::StackInfo,
DetailTab::Events,
DetailTab::Resources,
DetailTab::Outputs,
DetailTab::Parameters,
DetailTab::Template,
DetailTab::ChangeSets,
DetailTab::GitSync,
]
}
pub fn allows_preferences(&self) -> bool {
matches!(
self,
DetailTab::StackInfo
| DetailTab::Events
| DetailTab::Parameters
| DetailTab::Outputs
| DetailTab::Resources
| DetailTab::ChangeSets
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ResourceColumn {
LogicalId,
PhysicalId,
Type,
Status,
Module,
}
impl ResourceColumn {
pub fn id(&self) -> &'static str {
match self {
ResourceColumn::LogicalId => "cfn.resource.logical_id",
ResourceColumn::PhysicalId => "cfn.resource.physical_id",
ResourceColumn::Type => "cfn.resource.type",
ResourceColumn::Status => "cfn.resource.status",
ResourceColumn::Module => "cfn.resource.module",
}
}
pub fn default_name(&self) -> &'static str {
match self {
ResourceColumn::LogicalId => "Logical ID",
ResourceColumn::PhysicalId => "Physical ID",
ResourceColumn::Type => "Type",
ResourceColumn::Status => "Status",
ResourceColumn::Module => "Module",
}
}
pub fn all() -> Vec<ResourceColumn> {
vec![
ResourceColumn::LogicalId,
ResourceColumn::PhysicalId,
ResourceColumn::Type,
ResourceColumn::Status,
ResourceColumn::Module,
]
}
pub fn from_id(id: &str) -> Option<ResourceColumn> {
match id {
"cfn.resource.logical_id" => Some(ResourceColumn::LogicalId),
"cfn.resource.physical_id" => Some(ResourceColumn::PhysicalId),
"cfn.resource.type" => Some(ResourceColumn::Type),
"cfn.resource.status" => Some(ResourceColumn::Status),
"cfn.resource.module" => Some(ResourceColumn::Module),
_ => None,
}
}
}
pub fn resource_column_ids() -> Vec<ColumnId> {
ResourceColumn::all().iter().map(|c| c.id()).collect()
}
pub fn event_column_ids() -> Vec<ColumnId> {
EventColumn::all().iter().map(|c| c.id()).collect()
}
pub fn event_visible_column_ids() -> Vec<ColumnId> {
[
EventColumn::OperationId,
EventColumn::Timestamp,
EventColumn::LogicalId,
EventColumn::Status,
EventColumn::DetailedStatus,
EventColumn::StatusReason,
]
.iter()
.map(|c| c.id())
.collect()
}
pub fn filtered_events(app: &App) -> Vec<&StackEvent> {
filter_by_fields(
&app.cfn_state.events.items,
&app.cfn_state.events.filter,
|e| {
vec![
&e.logical_id,
&e.status,
&e.status_reason,
&e.resource_type,
&e.physical_id,
]
},
)
}
pub fn change_set_column_ids() -> Vec<ColumnId> {
ChangeSetColumn::all().iter().map(|c| c.id()).collect()
}
pub fn change_set_visible_column_ids() -> Vec<ColumnId> {
[
ChangeSetColumn::Name,
ChangeSetColumn::CreatedTime,
ChangeSetColumn::Status,
ChangeSetColumn::Description,
]
.iter()
.map(|c| c.id())
.collect()
}
pub fn filtered_change_sets(app: &App) -> Vec<&StackChangeSet> {
filter_by_fields(
&app.cfn_state.change_sets.items,
&app.cfn_state.change_sets.filter,
|cs| vec![&cs.name, &cs.status, &cs.description],
)
}
pub fn filtered_cloudformation_stacks(app: &App) -> Vec<&CfnStack> {
filter_by_fields(
&app.cfn_state.table.items,
&app.cfn_state.table.filter,
|s| vec![&s.name, &s.description],
)
.into_iter()
.filter(|s| app.cfn_state.status_filter.matches(&s.status))
.collect()
}
pub fn parameter_column_ids() -> Vec<ColumnId> {
ParameterColumn::all().iter().map(|c| c.id()).collect()
}
pub fn output_column_ids() -> Vec<ColumnId> {
OutputColumn::all().iter().map(|c| c.id()).collect()
}
pub fn filtered_parameters(app: &App) -> Vec<&StackParameter> {
filter_by_fields(
&app.cfn_state.parameters.items,
&app.cfn_state.parameters.filter,
|p| vec![&p.key, &p.value, &p.resolved_value],
)
}
pub fn filtered_outputs(app: &App) -> Vec<&StackOutput> {
filter_by_fields(
&app.cfn_state.outputs.items,
&app.cfn_state.outputs.filter,
|o| vec![&o.key, &o.value, &o.description, &o.export_name],
)
}
pub fn filtered_resources(app: &App) -> Vec<&StackResource> {
filter_by_fields(
&app.cfn_state.resources.items,
&app.cfn_state.resources.filter,
|r| {
vec![
&r.logical_id,
&r.physical_id,
&r.resource_type,
&r.status,
&r.module_info,
]
},
)
}
pub fn filtered_tags(app: &App) -> Vec<&(String, String)> {
filter_by_fields(
&app.cfn_state.tags.items,
&app.cfn_state.tags.filter,
|(k, v)| vec![k.as_str(), v.as_str()],
)
}
pub fn render_stacks(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Clear, area);
if app.cfn_state.current_stack.is_some() {
render_cloudformation_stack_detail(frame, app, area);
} else {
render_cloudformation_stack_list(frame, app, area);
}
}
pub fn render_cloudformation_stack_list(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(area);
let filtered_stacks = filtered_cloudformation_stacks(app);
let filtered_count = filtered_stacks.len();
let placeholder = "Search by stack name";
let status_filter_text = format!("Filter status: {}", app.cfn_state.status_filter.name());
let view_nested_text = if app.cfn_state.view_nested {
"☑ View nested"
} else {
"☐ View nested"
};
let page_size = app.cfn_state.table.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page =
if filtered_count > 0 && app.cfn_state.table.scroll_offset + page_size >= filtered_count {
total_pages.saturating_sub(1)
} else {
app.cfn_state.table.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.table.filter,
placeholder,
mode: app.mode,
is_input_focused: app.cfn_state.input_focus == InputFocus::Filter,
controls: vec![
FilterControl {
text: status_filter_text.to_string(),
is_focused: app.cfn_state.input_focus == STATUS_FILTER,
},
FilterControl {
text: view_nested_text.to_string(),
is_focused: app.cfn_state.input_focus == VIEW_NESTED,
},
FilterControl {
text: pagination.clone(),
is_focused: app.cfn_state.input_focus == InputFocus::Pagination,
},
],
area: chunks[0],
},
);
let scroll_offset = app.cfn_state.table.scroll_offset;
let page_stacks: Vec<_> = filtered_stacks
.iter()
.skip(scroll_offset)
.take(page_size)
.collect();
let column_enums: Vec<CfnColumn> = app
.cfn_visible_column_ids
.iter()
.filter_map(|col_id| CfnColumn::from_id(col_id))
.collect();
let columns: Vec<Box<dyn Column<&CfnStack>>> =
column_enums.iter().map(|col| col.to_column()).collect();
let expanded_index = app.cfn_state.table.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.table.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_stacks,
selected_index: app.cfn_state.table.selected % app.cfn_state.table.page_size.value(),
expanded_index,
columns: &columns,
sort_column: app.cfn_state.sort_column.default_name(),
sort_direction: app.cfn_state.sort_direction,
title: format_title(&format!("Stacks ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|stack: &&CfnStack| {
expanded_from_columns(&columns, stack)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
if app.mode == Mode::FilterInput && app.cfn_state.input_focus == STATUS_FILTER {
let filter_names: Vec<&str> = StatusFilter::all().iter().map(|f| f.name()).collect();
let selected_idx = StatusFilter::all()
.iter()
.position(|f| *f == app.cfn_state.status_filter)
.unwrap_or(0);
let view_nested_width = " ☑ View nested ".len() as u16;
let controls_after = view_nested_width + 3 + pagination.len() as u16 + 3;
render_dropdown(
frame,
&filter_names,
selected_idx,
chunks[0],
controls_after,
);
}
}
pub fn render_cloudformation_stack_detail(frame: &mut Frame, app: &App, area: Rect) {
let stack_name = app.cfn_state.current_stack.as_ref().unwrap();
let stack = app
.cfn_state
.table
.items
.iter()
.find(|s| &s.name == stack_name);
if stack.is_none() {
let paragraph = Paragraph::new("Stack not found").block(titled_block("Error"));
frame.render_widget(paragraph, area);
return;
}
let stack = stack.unwrap();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), ])
.split(area);
let tabs: Vec<_> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
render_tabs(frame, chunks[0], &tabs, &app.cfn_state.detail_tab);
match app.cfn_state.detail_tab {
DetailTab::StackInfo => {
render_stack_info(frame, app, stack, chunks[1]);
}
DetailTab::GitSync => {
render_git_sync(frame, app, stack, chunks[1]);
}
DetailTab::Template => {
render_template_highlighted(
frame,
chunks[1],
&app.cfn_state.template_body,
app.cfn_state.template_scroll,
" Template ",
true,
);
}
DetailTab::Parameters => {
render_parameters(frame, app, chunks[1]);
}
DetailTab::Outputs => {
render_outputs(frame, app, chunks[1]);
}
DetailTab::Resources => {
render_resources(frame, app, chunks[1]);
}
DetailTab::Events => {
render_events(frame, app, chunks[1]);
}
DetailTab::ChangeSets => {
render_change_sets(frame, app, chunks[1]);
}
}
}
pub fn render_stack_info(frame: &mut Frame, app: &App, stack: &CfnStack, area: Rect) {
let (formatted_status, _status_color) = format_status(&stack.status);
let fields = vec![
(
"Stack ID",
if stack.stack_id.is_empty() {
"-"
} else {
&stack.stack_id
},
),
(
"Description",
if stack.description.is_empty() {
"-"
} else {
&stack.description
},
),
("Status", &formatted_status),
(
"Detailed status",
if stack.detailed_status.is_empty() {
"-"
} else {
&stack.detailed_status
},
),
(
"Status reason",
if stack.status_reason.is_empty() {
"-"
} else {
&stack.status_reason
},
),
(
"Root stack",
if stack.root_stack.is_empty() {
"-"
} else {
&stack.root_stack
},
),
(
"Parent stack",
if stack.parent_stack.is_empty() {
"-"
} else {
&stack.parent_stack
},
),
(
"Created time",
if stack.created_time.is_empty() {
"-"
} else {
&stack.created_time
},
),
(
"Updated time",
if stack.updated_time.is_empty() {
"-"
} else {
&stack.updated_time
},
),
(
"Deleted time",
if stack.deleted_time.is_empty() {
"-"
} else {
&stack.deleted_time
},
),
(
"Drift status",
if stack.drift_status.is_empty() {
"-"
} else if stack.drift_status == "NOT_CHECKED" || stack.drift_status == "NotChecked" {
"⭕ NOT CHECKED"
} else {
&stack.drift_status
},
),
(
"Last drift check time",
if stack.last_drift_check_time.is_empty() {
"-"
} else {
&stack.last_drift_check_time
},
),
(
"Termination protection",
if stack.termination_protection {
"Activated"
} else {
"Disabled"
},
),
(
"IAM role",
if stack.iam_role.is_empty() {
"-"
} else {
&stack.iam_role
},
),
];
let overview_height = calculate_dynamic_height(
&fields
.iter()
.map(|(label, value)| labeled_field(label, *value))
.collect::<Vec<_>>(),
area.width.saturating_sub(4),
) + 2;
let tags_height = 12;
let policy_height = 15;
let rollback_lines: Vec<Line> = {
let mut lines = vec![labeled_field(
"Monitoring time",
if stack.rollback_monitoring_time.is_empty() {
"-"
} else {
&stack.rollback_monitoring_time
},
)];
if stack.rollback_alarms.is_empty() {
lines.push(Line::from("CloudWatch alarm ARN: No alarms configured"));
} else {
for alarm in &stack.rollback_alarms {
lines.push(Line::from(format!("CloudWatch alarm ARN: {}", alarm)));
}
}
lines
};
let rollback_height =
calculate_dynamic_height(&rollback_lines, area.width.saturating_sub(4)) + 2;
let notification_lines = if stack.notification_arns.is_empty() {
vec!["SNS topic ARN: No notifications configured".to_string()]
} else {
stack
.notification_arns
.iter()
.map(|arn| format!("SNS topic ARN: {}", arn))
.collect()
};
let notification_height = notification_lines.len() as u16 + 2;
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(overview_height),
Constraint::Length(tags_height),
Constraint::Length(policy_height),
Constraint::Length(rollback_height),
Constraint::Length(notification_height),
Constraint::Min(0), ])
.split(area);
let overview_lines: Vec<_> = fields
.iter()
.map(|(label, value)| labeled_field(label, *value))
.collect();
let overview_block = titled_block("Overview");
let overview_inner = overview_block.inner(sections[0]);
frame.render_widget(overview_block, sections[0]);
render_fields_with_dynamic_columns(frame, overview_inner, overview_lines);
render_tags(frame, app, sections[1]);
let policy_text = if stack.stack_policy.is_empty() {
"No stack policy".to_string()
} else {
stack.stack_policy.clone()
};
render_json_highlighted(
frame,
sections[2],
&policy_text,
app.cfn_state.policy_scroll,
" Stack policy ",
true,
);
let rollback_block = titled_block("Rollback configuration");
let rollback_inner = rollback_block.inner(sections[3]);
frame.render_widget(rollback_block, sections[3]);
render_fields_with_dynamic_columns(frame, rollback_inner, rollback_lines);
let notifications = Paragraph::new(notification_lines.join("\n"))
.block(titled_block("Notification options"))
.wrap(Wrap { trim: true });
frame.render_widget(notifications, sections[4]);
}
fn render_tags(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let filtered: Vec<&(String, String)> = filtered_tags(app);
let filtered_count = filtered.len();
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.tags.filter,
placeholder: "Search tags",
mode: app.mode,
is_input_focused: false,
controls: vec![],
area: chunks[0],
},
);
let page_size = app.cfn_state.tags.page_size.value();
let page_start = app.cfn_state.tags.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_tags: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<(String, String)>>> =
vec![Box::new(TagColumn::Key), Box::new(TagColumn::Value)];
let expanded_index = app.cfn_state.tags.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.tags.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_tags,
selected_index: app.cfn_state.tags.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Key",
sort_direction: SortDirection::Asc,
title: format_title(&format!("Tags ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|tag: &(String, String)| {
expanded_from_columns(&columns, tag)
})),
is_active: true,
};
render_table(frame, config);
}
fn render_git_sync(frame: &mut Frame, _app: &App, _stack: &CfnStack, area: Rect) {
let fields = [
("Repository", "-"),
("Deployment file path", "-"),
("Git sync", "-"),
("Repository provider", "-"),
("Repository sync status", "-"),
("Provisioning status", "-"),
("Branch", "-"),
("Repository sync status message", "-"),
];
let lines: Vec<Line> = fields
.iter()
.map(|&(label, value)| labeled_field(label, value))
.collect();
let git_sync_height = calculate_dynamic_height(&lines, area.width.saturating_sub(4)) + 2;
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(git_sync_height), Constraint::Min(0)])
.split(area);
let block = titled_block("Git sync");
let inner = block.inner(sections[0]);
frame.render_widget(block, sections[0]);
render_fields_with_dynamic_columns(frame, inner, lines);
}
#[cfg(test)]
mod tests {
use super::{filtered_tags, State};
use crate::app::App;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
#[test]
fn test_drift_status_not_checked_formatting() {
let drift_status = "NOT_CHECKED";
let formatted = if drift_status == "NOT_CHECKED" {
"⭕ NOT CHECKED"
} else {
drift_status
};
assert_eq!(formatted, "⭕ NOT CHECKED");
}
#[test]
fn test_drift_status_not_checked_pascal_case() {
let drift_status = "NotChecked";
let formatted = if drift_status == "NOT_CHECKED" || drift_status == "NotChecked" {
"⭕ NOT CHECKED"
} else {
drift_status
};
assert_eq!(formatted, "⭕ NOT CHECKED");
}
#[test]
fn test_drift_status_other_values() {
let drift_status = "IN_SYNC";
let formatted = if drift_status == "NOT_CHECKED" {
"⭕ NOT CHECKED"
} else {
drift_status
};
assert_eq!(formatted, "IN_SYNC");
}
#[test]
fn test_git_sync_renders_all_fields() {
use crate::cfn::Stack;
let stack = Stack {
name: "test-stack".to_string(),
stack_id: "id".to_string(),
status: "CREATE_COMPLETE".to_string(),
created_time: String::new(),
updated_time: String::new(),
deleted_time: String::new(),
drift_status: String::new(),
last_drift_check_time: String::new(),
status_reason: String::new(),
description: String::new(),
detailed_status: String::new(),
root_stack: String::new(),
parent_stack: String::new(),
termination_protection: false,
iam_role: String::new(),
tags: Vec::new(),
stack_policy: String::new(),
rollback_monitoring_time: String::new(),
rollback_alarms: Vec::new(),
notification_arns: Vec::new(),
};
let fields = [
"Repository",
"Deployment file path",
"Git sync",
"Repository provider",
"Repository sync status",
"Provisioning status",
"Branch",
"Repository sync status message",
];
assert_eq!(fields.len(), 8);
assert_eq!(stack.name, "test-stack");
}
#[test]
fn test_git_sync_uses_dynamic_columns() {
use crate::ui::calculate_dynamic_height;
use ratatui::text::Line;
let fields = [
("Repository", "-"),
("Deployment file path", "-"),
("Git sync", "-"),
("Repository provider", "-"),
("Repository sync status", "-"),
("Provisioning status", "-"),
("Branch", "-"),
("Repository sync status message", "-"),
];
let lines: Vec<Line> = fields
.iter()
.map(|&(label, value)| crate::ui::labeled_field(label, value))
.collect();
let narrow_height = calculate_dynamic_height(&lines, 40);
assert!(
narrow_height >= 8,
"8 fields in 1 column should need at least 8 rows, got {narrow_height}"
);
let wide_height = calculate_dynamic_height(&lines, 240);
assert!(
wide_height < narrow_height,
"Wide layout should be shorter than narrow: wide={wide_height} narrow={narrow_height}"
);
}
#[test]
fn test_notification_arn_format() {
use crate::cfn::Stack;
let stack_with_notifications = Stack {
name: "test-stack".to_string(),
stack_id: "id".to_string(),
status: "CREATE_COMPLETE".to_string(),
created_time: String::new(),
updated_time: String::new(),
deleted_time: String::new(),
drift_status: String::new(),
last_drift_check_time: String::new(),
status_reason: String::new(),
description: String::new(),
detailed_status: String::new(),
root_stack: String::new(),
parent_stack: String::new(),
termination_protection: false,
iam_role: String::new(),
tags: Vec::new(),
stack_policy: String::new(),
rollback_monitoring_time: String::new(),
rollback_alarms: Vec::new(),
notification_arns: vec![
"arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications".to_string(),
],
};
let notification_lines: Vec<String> = stack_with_notifications
.notification_arns
.iter()
.map(|arn| format!("SNS topic ARN: {}", arn))
.collect();
assert_eq!(notification_lines.len(), 1);
assert_eq!(
notification_lines[0],
"SNS topic ARN: arn:aws:sns:us-east-1:588850195596:CloudFormationNotifications"
);
let stack_without_notifications = Stack {
name: "test-stack".to_string(),
stack_id: "id".to_string(),
status: "CREATE_COMPLETE".to_string(),
created_time: String::new(),
updated_time: String::new(),
deleted_time: String::new(),
drift_status: String::new(),
last_drift_check_time: String::new(),
status_reason: String::new(),
description: String::new(),
detailed_status: String::new(),
root_stack: String::new(),
parent_stack: String::new(),
termination_protection: false,
iam_role: String::new(),
tags: Vec::new(),
stack_policy: String::new(),
rollback_monitoring_time: String::new(),
rollback_alarms: Vec::new(),
notification_arns: Vec::new(),
};
let notification_lines: Vec<String> =
if stack_without_notifications.notification_arns.is_empty() {
vec!["SNS topic ARN: No notifications configured".to_string()]
} else {
stack_without_notifications
.notification_arns
.iter()
.map(|arn| format!("SNS topic ARN: {}", arn))
.collect()
};
assert_eq!(notification_lines.len(), 1);
assert_eq!(
notification_lines[0],
"SNS topic ARN: No notifications configured"
);
}
#[test]
fn test_template_scroll_state() {
let state = State::new();
assert_eq!(state.template_body, "");
assert_eq!(state.template_scroll, 0);
}
#[test]
fn test_template_body_storage() {
let mut state = State::new();
let template = r#"{"AWSTemplateFormatVersion":"2010-09-09","Resources":{}}"#;
state.template_body = template.to_string();
assert_eq!(state.template_body, template);
}
#[test]
fn test_rollback_alarm_format() {
let alarm_arn = "arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm";
let formatted = format!("CloudWatch alarm ARN: {}", alarm_arn);
assert_eq!(
formatted,
"CloudWatch alarm ARN: arn:aws:cloudwatch:us-east-1:123456789012:alarm:MyAlarm"
);
}
#[test]
fn test_filtered_tags() {
let mut app = test_app();
app.cfn_state.tags.items = vec![
("Environment".to_string(), "Production".to_string()),
("Application".to_string(), "WebApp".to_string()),
("Owner".to_string(), "TeamA".to_string()),
];
app.cfn_state.tags.filter = String::new();
let filtered = filtered_tags(&app);
assert_eq!(filtered.len(), 3);
app.cfn_state.tags.filter = "env".to_string();
let filtered = filtered_tags(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "Environment");
app.cfn_state.tags.filter = "prod".to_string();
let filtered = filtered_tags(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].1, "Production");
}
#[test]
fn test_tags_sorted_by_key() {
let mut tags = [
("Zebra".to_string(), "value1".to_string()),
("Alpha".to_string(), "value2".to_string()),
("Beta".to_string(), "value3".to_string()),
];
tags.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(tags[0].0, "Alpha");
assert_eq!(tags[1].0, "Beta");
assert_eq!(tags[2].0, "Zebra");
}
#[test]
fn test_policy_scroll_state() {
let state = State::new();
assert_eq!(state.policy_scroll, 0);
}
#[test]
fn test_rounded_block_for_rollback_config() {
use crate::ui::titled_block;
use ratatui::prelude::Rect;
let block = titled_block("Rollback configuration");
let area = Rect::new(0, 0, 90, 8);
let inner = block.inner(area);
assert_eq!(inner.width, 88);
assert_eq!(inner.height, 6);
}
#[test]
fn test_overview_uses_dynamic_height() {
use crate::ui::{calculate_dynamic_height, labeled_field};
let fields = vec![
labeled_field("Stack name", "test-stack"),
labeled_field("Status", "CREATE_COMPLETE"),
labeled_field("Created", "2024-01-01"),
];
let width = 150;
let height = calculate_dynamic_height(&fields, width);
assert!(height < 3, "Expected fewer than 3 rows with column packing");
}
#[test]
fn test_resource_status_uses_format_status_emoji_and_color() {
use super::ResourceColumn;
use crate::ui::table::Column;
use ratatui::style::Color;
use rusticity_core::cfn::StackResource;
let col = ResourceColumn::Status;
let res = StackResource {
logical_id: "MyBucket".into(),
physical_id: "my-bucket".into(),
resource_type: "AWS::S3::Bucket".into(),
status: "CREATE_COMPLETE".into(),
module_info: String::new(),
};
let (text, style) = col.render(&res);
assert!(
text.contains("✅"),
"CREATE_COMPLETE should have ✅ emoji, got: {text}"
);
assert_eq!(
style.fg,
Some(Color::Green),
"CREATE_COMPLETE should be green"
);
let res_failed = StackResource {
status: "CREATE_FAILED".into(),
..res.clone()
};
let (text, style) = col.render(&res_failed);
assert!(
text.contains("❌"),
"CREATE_FAILED should have ❌ emoji, got: {text}"
);
assert_eq!(style.fg, Some(Color::Red), "CREATE_FAILED should be red");
let res_ip = StackResource {
status: "CREATE_IN_PROGRESS".into(),
..res.clone()
};
let (text, style) = col.render(&res_ip);
assert!(
text.contains("ℹ️"),
"CREATE_IN_PROGRESS should have ℹ️ emoji, got: {text}"
);
assert_eq!(
style.fg,
Some(Color::Blue),
"CREATE_IN_PROGRESS should be blue"
);
}
#[test]
fn test_resource_status_column_width_fits_emoji_and_status() {
use super::ResourceColumn;
use crate::ui::table::Column;
let col = ResourceColumn::Status;
assert!(
col.width() >= 35,
"Status column width {} is too narrow for emoji+status",
col.width()
);
}
}
pub fn render_parameters(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let filtered: Vec<&StackParameter> = if app.cfn_state.parameters.filter.is_empty() {
app.cfn_state.parameters.items.iter().collect()
} else {
app.cfn_state
.parameters
.items
.iter()
.filter(|p| {
p.key
.to_lowercase()
.contains(&app.cfn_state.parameters.filter.to_lowercase())
|| p.value
.to_lowercase()
.contains(&app.cfn_state.parameters.filter.to_lowercase())
|| p.resolved_value
.to_lowercase()
.contains(&app.cfn_state.parameters.filter.to_lowercase())
})
.collect()
};
let filtered_count = filtered.len();
let page_size = app.cfn_state.parameters.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page = if filtered_count > 0
&& app.cfn_state.parameters.scroll_offset + page_size >= filtered_count
{
total_pages.saturating_sub(1)
} else {
app.cfn_state.parameters.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.parameters.filter,
placeholder: "Search parameters",
mode: app.mode,
is_input_focused: app.cfn_state.parameters_input_focus == InputFocus::Filter,
controls: vec![FilterControl {
text: pagination,
is_focused: app.cfn_state.parameters_input_focus == InputFocus::Pagination,
}],
area: chunks[0],
},
);
let page_start = app.cfn_state.parameters.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_params: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<StackParameter>>> = app
.cfn_parameter_visible_column_ids
.iter()
.filter_map(|col_id| {
ParameterColumn::from_id(col_id)
.map(|col| Box::new(col) as Box<dyn Column<StackParameter>>)
})
.collect();
let expanded_index = app.cfn_state.parameters.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.parameters.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_params,
selected_index: app.cfn_state.parameters.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Key",
sort_direction: SortDirection::Asc,
title: format_title(&format!("Parameters ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|param: &StackParameter| {
expanded_from_columns(&columns, param)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
}
pub fn render_outputs(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let filtered: Vec<&StackOutput> = if app.cfn_state.outputs.filter.is_empty() {
app.cfn_state.outputs.items.iter().collect()
} else {
app.cfn_state
.outputs
.items
.iter()
.filter(|o| {
o.key
.to_lowercase()
.contains(&app.cfn_state.outputs.filter.to_lowercase())
|| o.value
.to_lowercase()
.contains(&app.cfn_state.outputs.filter.to_lowercase())
|| o.description
.to_lowercase()
.contains(&app.cfn_state.outputs.filter.to_lowercase())
|| o.export_name
.to_lowercase()
.contains(&app.cfn_state.outputs.filter.to_lowercase())
})
.collect()
};
let filtered_count = filtered.len();
let page_size = app.cfn_state.outputs.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page = if filtered_count > 0
&& app.cfn_state.outputs.scroll_offset + page_size >= filtered_count
{
total_pages.saturating_sub(1)
} else {
app.cfn_state.outputs.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.outputs.filter,
placeholder: "Search outputs",
mode: app.mode,
is_input_focused: app.cfn_state.outputs_input_focus == InputFocus::Filter,
controls: vec![FilterControl {
text: pagination,
is_focused: app.cfn_state.outputs_input_focus == InputFocus::Pagination,
}],
area: chunks[0],
},
);
let page_start = app.cfn_state.outputs.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_outputs: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<StackOutput>>> = app
.cfn_output_visible_column_ids
.iter()
.filter_map(|col_id| {
OutputColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<StackOutput>>)
})
.collect();
let expanded_index = app.cfn_state.outputs.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.outputs.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_outputs,
selected_index: app.cfn_state.outputs.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Key",
sort_direction: SortDirection::Asc,
title: format_title(&format!("Outputs ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|output: &StackOutput| {
expanded_from_columns(&columns, output)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
}
pub fn render_resources(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let filtered: Vec<&StackResource> = filtered_resources(app);
let filtered_count = filtered.len();
let page_size = app.cfn_state.resources.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page = if filtered_count > 0
&& app.cfn_state.resources.scroll_offset + page_size >= filtered_count
{
total_pages.saturating_sub(1)
} else {
app.cfn_state.resources.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.resources.filter,
placeholder: "Search resources",
mode: app.mode,
is_input_focused: app.cfn_state.resources_input_focus == InputFocus::Filter,
controls: vec![FilterControl {
text: pagination,
is_focused: app.cfn_state.resources_input_focus == InputFocus::Pagination,
}],
area: chunks[0],
},
);
let page_start = app.cfn_state.resources.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_resources: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<StackResource>>> = app
.cfn_resource_visible_column_ids
.iter()
.filter_map(|col_id| {
ResourceColumn::from_id(col_id)
.map(|col| Box::new(col) as Box<dyn Column<StackResource>>)
})
.collect();
let expanded_index = app.cfn_state.resources.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.resources.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_resources,
selected_index: app.cfn_state.resources.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Logical ID",
sort_direction: SortDirection::Asc,
title: format_title(&format!("Resources ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|resource: &StackResource| {
expanded_from_columns(&columns, resource)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
}
pub fn render_events(frame: &mut Frame, app: &App, area: Rect) {
render_events_table(frame, app, area);
}
fn render_events_table(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(area);
let filtered = filtered_events(app);
let filtered_count = filtered.len();
let page_size = app.cfn_state.events.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page =
if filtered_count > 0 && app.cfn_state.events.scroll_offset + page_size >= filtered_count {
total_pages.saturating_sub(1)
} else {
app.cfn_state.events.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.events.filter,
placeholder: "Search events",
mode: app.mode,
is_input_focused: app.cfn_state.events_input_focus == InputFocus::Filter,
controls: vec![FilterControl {
text: pagination,
is_focused: app.cfn_state.events_input_focus == InputFocus::Pagination,
}],
area: chunks[0],
},
);
let page_start = app.cfn_state.events.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_events: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<StackEvent>>> = app
.cfn_event_visible_column_ids
.iter()
.filter_map(|col_id| {
EventColumn::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<StackEvent>>)
})
.collect();
let expanded_index = app.cfn_state.events.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.events.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_events,
selected_index: app.cfn_state.events.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Timestamp",
sort_direction: SortDirection::Desc,
title: format_title(&format!("Events ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|event: &StackEvent| {
expanded_from_columns(&columns, event)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
}
pub fn render_change_sets(frame: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let filtered = filtered_change_sets(app);
let filtered_count = filtered.len();
let page_size = app.cfn_state.change_sets.page_size.value();
let total_pages = filtered_count.div_ceil(page_size);
let current_page = if filtered_count > 0
&& app.cfn_state.change_sets.scroll_offset + page_size >= filtered_count
{
total_pages.saturating_sub(1)
} else {
app.cfn_state.change_sets.scroll_offset / page_size
};
let pagination = render_pagination_text(current_page, total_pages);
render_filter_bar(
frame,
FilterConfig {
filter_text: &app.cfn_state.change_sets.filter,
placeholder: "Search change sets",
mode: app.mode,
is_input_focused: app.cfn_state.change_sets_input_focus == InputFocus::Filter,
controls: vec![FilterControl {
text: pagination,
is_focused: app.cfn_state.change_sets_input_focus == InputFocus::Pagination,
}],
area: chunks[0],
},
);
let page_start = app.cfn_state.change_sets.scroll_offset;
let page_end = (page_start + page_size).min(filtered_count);
let page_items: Vec<_> = filtered[page_start..page_end].to_vec();
let columns: Vec<Box<dyn Column<StackChangeSet>>> = app
.cfn_change_set_visible_column_ids
.iter()
.filter_map(|col_id| {
ChangeSetColumn::from_id(col_id)
.map(|col| Box::new(col) as Box<dyn Column<StackChangeSet>>)
})
.collect();
let expanded_index = app.cfn_state.change_sets.expanded_item.and_then(|idx| {
let scroll_offset = app.cfn_state.change_sets.scroll_offset;
if idx >= scroll_offset && idx < scroll_offset + page_size {
Some(idx - scroll_offset)
} else {
None
}
});
let config = TableConfig {
items: page_items,
selected_index: app.cfn_state.change_sets.selected % page_size,
expanded_index,
columns: &columns,
sort_column: "Created time",
sort_direction: SortDirection::Desc,
title: format_title(&format!("Change sets ({})", filtered_count)),
area: chunks[1],
get_expanded_content: Some(Box::new(|cs: &StackChangeSet| {
expanded_from_columns(&columns, cs)
})),
is_active: app.mode != Mode::FilterInput,
};
render_table(frame, config);
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ParameterColumn {
Key,
Value,
ResolvedValue,
}
impl ParameterColumn {
fn id(&self) -> &'static str {
match self {
ParameterColumn::Key => "cfn.parameter.key",
ParameterColumn::Value => "cfn.parameter.value",
ParameterColumn::ResolvedValue => "cfn.parameter.resolved_value",
}
}
pub fn default_name(&self) -> &'static str {
match self {
ParameterColumn::Key => "Key",
ParameterColumn::Value => "Value",
ParameterColumn::ResolvedValue => "Resolved value",
}
}
pub fn all() -> Vec<Self> {
vec![Self::Key, Self::Value, Self::ResolvedValue]
}
pub fn from_id(id: &str) -> Option<Self> {
match id {
"cfn.parameter.key" => Some(Self::Key),
"cfn.parameter.value" => Some(Self::Value),
"cfn.parameter.resolved_value" => Some(Self::ResolvedValue),
_ => None,
}
}
}
impl Column<StackParameter> for ParameterColumn {
fn id(&self) -> &'static str {
Self::id(self)
}
fn default_name(&self) -> &'static str {
Self::default_name(self)
}
fn width(&self) -> u16 {
let translated = translate_column(self.id(), self.default_name());
translated.len().max(match self {
ParameterColumn::Key => 40,
ParameterColumn::Value => 50,
ParameterColumn::ResolvedValue => 50,
}) as u16
}
fn render(&self, item: &StackParameter) -> (String, Style) {
match self {
ParameterColumn::Key => (item.key.clone(), Style::default()),
ParameterColumn::Value => (item.value.clone(), Style::default()),
ParameterColumn::ResolvedValue => (item.resolved_value.clone(), Style::default()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OutputColumn {
Key,
Value,
Description,
ExportName,
}
impl OutputColumn {
fn id(&self) -> &'static str {
match self {
OutputColumn::Key => "cfn.output.key",
OutputColumn::Value => "cfn.output.value",
OutputColumn::Description => "cfn.output.description",
OutputColumn::ExportName => "cfn.output.export_name",
}
}
pub fn default_name(&self) -> &'static str {
match self {
OutputColumn::Key => "Key",
OutputColumn::Value => "Value",
OutputColumn::Description => "Description",
OutputColumn::ExportName => "Export name",
}
}
pub fn all() -> Vec<Self> {
vec![Self::Key, Self::Value, Self::Description, Self::ExportName]
}
pub fn from_id(id: &str) -> Option<Self> {
match id {
"cfn.output.key" => Some(Self::Key),
"cfn.output.value" => Some(Self::Value),
"cfn.output.description" => Some(Self::Description),
"cfn.output.export_name" => Some(Self::ExportName),
_ => None,
}
}
}
impl Column<StackOutput> for OutputColumn {
fn id(&self) -> &'static str {
Self::id(self)
}
fn default_name(&self) -> &'static str {
Self::default_name(self)
}
fn width(&self) -> u16 {
let translated = translate_column(self.id(), self.default_name());
translated.len().max(match self {
OutputColumn::Key => 40,
OutputColumn::Value => 50,
OutputColumn::Description => 50,
OutputColumn::ExportName => 40,
}) as u16
}
fn render(&self, item: &StackOutput) -> (String, Style) {
match self {
OutputColumn::Key => (item.key.clone(), Style::default()),
OutputColumn::Value => (item.value.clone(), Style::default()),
OutputColumn::Description => (item.description.clone(), Style::default()),
OutputColumn::ExportName => (item.export_name.clone(), Style::default()),
}
}
}
impl Column<StackResource> for ResourceColumn {
fn id(&self) -> &'static str {
Self::id(self)
}
fn default_name(&self) -> &'static str {
Self::default_name(self)
}
fn width(&self) -> u16 {
let translated = translate_column(self.id(), self.default_name());
translated.len().max(match self {
ResourceColumn::LogicalId => 40,
ResourceColumn::PhysicalId => 50,
ResourceColumn::Type => 40,
ResourceColumn::Status => 35,
ResourceColumn::Module => 40,
}) as u16
}
fn render(&self, item: &StackResource) -> (String, Style) {
match self {
ResourceColumn::LogicalId => (item.logical_id.clone(), Style::default()),
ResourceColumn::PhysicalId => (item.physical_id.clone(), Style::default()),
ResourceColumn::Type => (item.resource_type.clone(), Style::default()),
ResourceColumn::Status => {
let (formatted, color) = format_status(&item.status);
(formatted, Style::default().fg(color))
}
ResourceColumn::Module => (item.module_info.clone(), Style::default()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum EventColumn {
OperationId,
Timestamp,
LogicalId,
Status,
DetailedStatus,
StatusReason,
HookInvocationCount,
Type,
PhysicalId,
ClientRequestToken,
}
impl EventColumn {
const ID_OPERATION_ID: &'static str = "column.cfn.event.operation_id";
const ID_TIMESTAMP: &'static str = "column.cfn.event.timestamp";
const ID_LOGICAL_ID: &'static str = "column.cfn.event.logical_id";
const ID_STATUS: &'static str = "column.cfn.event.status";
const ID_DETAILED_STATUS: &'static str = "column.cfn.event.detailed_status";
const ID_STATUS_REASON: &'static str = "column.cfn.event.status_reason";
const ID_HOOK_INVOCATION_COUNT: &'static str = "column.cfn.event.hook_invocation_count";
const ID_TYPE: &'static str = "column.cfn.event.type";
const ID_PHYSICAL_ID: &'static str = "column.cfn.event.physical_id";
const ID_CLIENT_REQUEST_TOKEN: &'static str = "column.cfn.event.client_request_token";
pub const fn id(&self) -> &'static str {
match self {
EventColumn::OperationId => Self::ID_OPERATION_ID,
EventColumn::Timestamp => Self::ID_TIMESTAMP,
EventColumn::LogicalId => Self::ID_LOGICAL_ID,
EventColumn::Status => Self::ID_STATUS,
EventColumn::DetailedStatus => Self::ID_DETAILED_STATUS,
EventColumn::StatusReason => Self::ID_STATUS_REASON,
EventColumn::HookInvocationCount => Self::ID_HOOK_INVOCATION_COUNT,
EventColumn::Type => Self::ID_TYPE,
EventColumn::PhysicalId => Self::ID_PHYSICAL_ID,
EventColumn::ClientRequestToken => Self::ID_CLIENT_REQUEST_TOKEN,
}
}
pub const fn default_name(&self) -> &'static str {
match self {
EventColumn::OperationId => "Operation ID",
EventColumn::Timestamp => "Timestamp",
EventColumn::LogicalId => "Logical ID",
EventColumn::Status => "Status",
EventColumn::DetailedStatus => "Detailed status",
EventColumn::StatusReason => "Status reason",
EventColumn::HookInvocationCount => "Hook invocations",
EventColumn::Type => "Type",
EventColumn::PhysicalId => "Physical ID",
EventColumn::ClientRequestToken => "Client request token",
}
}
pub fn all() -> Vec<EventColumn> {
vec![
EventColumn::OperationId,
EventColumn::Timestamp,
EventColumn::LogicalId,
EventColumn::Status,
EventColumn::DetailedStatus,
EventColumn::StatusReason,
EventColumn::HookInvocationCount,
EventColumn::Type,
EventColumn::PhysicalId,
EventColumn::ClientRequestToken,
]
}
pub fn from_id(id: &str) -> Option<EventColumn> {
match id {
Self::ID_OPERATION_ID => Some(EventColumn::OperationId),
Self::ID_TIMESTAMP => Some(EventColumn::Timestamp),
Self::ID_LOGICAL_ID => Some(EventColumn::LogicalId),
Self::ID_STATUS => Some(EventColumn::Status),
Self::ID_DETAILED_STATUS => Some(EventColumn::DetailedStatus),
Self::ID_STATUS_REASON => Some(EventColumn::StatusReason),
Self::ID_HOOK_INVOCATION_COUNT => Some(EventColumn::HookInvocationCount),
Self::ID_TYPE => Some(EventColumn::Type),
Self::ID_PHYSICAL_ID => Some(EventColumn::PhysicalId),
Self::ID_CLIENT_REQUEST_TOKEN => Some(EventColumn::ClientRequestToken),
_ => None,
}
}
}
impl Column<StackEvent> for EventColumn {
fn id(&self) -> &'static str {
Self::id(self)
}
fn default_name(&self) -> &'static str {
Self::default_name(self)
}
fn width(&self) -> u16 {
let translated = translate_column(self.id(), self.default_name());
translated.len().max(match self {
EventColumn::OperationId => 20,
EventColumn::Timestamp => UTC_TIMESTAMP_WIDTH as usize,
EventColumn::LogicalId => 40,
EventColumn::Status => 35,
EventColumn::DetailedStatus => 30,
EventColumn::StatusReason => 50,
EventColumn::HookInvocationCount => 18,
EventColumn::Type => 40,
EventColumn::PhysicalId => 50,
EventColumn::ClientRequestToken => 30,
}) as u16
}
fn render(&self, item: &StackEvent) -> (String, Style) {
match self {
EventColumn::OperationId => (item.event_id.clone(), Style::default()),
EventColumn::Timestamp => (format_iso_timestamp(&item.timestamp), Style::default()),
EventColumn::LogicalId => (item.logical_id.clone(), Style::default()),
EventColumn::Status => {
let (formatted, color) = format_status(&item.status);
(formatted, Style::default().fg(color))
}
EventColumn::DetailedStatus => (item.detailed_status.clone(), Style::default()),
EventColumn::StatusReason => (item.status_reason.clone(), Style::default()),
EventColumn::HookInvocationCount => {
(item.hook_invocation_count.clone(), Style::default())
}
EventColumn::Type => (item.resource_type.clone(), Style::default()),
EventColumn::PhysicalId => (item.physical_id.clone(), Style::default()),
EventColumn::ClientRequestToken => {
(item.client_request_token.clone(), Style::default())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ChangeSetColumn {
Name,
CreatedTime,
Status,
Description,
RootChangeSetId,
ParentChangeSetId,
}
impl ChangeSetColumn {
const ID_NAME: &'static str = "column.cfn.changeset.name";
const ID_CREATED_TIME: &'static str = "column.cfn.changeset.created_time";
const ID_STATUS: &'static str = "column.cfn.changeset.status";
const ID_DESCRIPTION: &'static str = "column.cfn.changeset.description";
const ID_ROOT_CHANGE_SET_ID: &'static str = "column.cfn.changeset.root_change_set_id";
const ID_PARENT_CHANGE_SET_ID: &'static str = "column.cfn.changeset.parent_change_set_id";
pub const fn id(&self) -> &'static str {
match self {
ChangeSetColumn::Name => Self::ID_NAME,
ChangeSetColumn::CreatedTime => Self::ID_CREATED_TIME,
ChangeSetColumn::Status => Self::ID_STATUS,
ChangeSetColumn::Description => Self::ID_DESCRIPTION,
ChangeSetColumn::RootChangeSetId => Self::ID_ROOT_CHANGE_SET_ID,
ChangeSetColumn::ParentChangeSetId => Self::ID_PARENT_CHANGE_SET_ID,
}
}
pub const fn default_name(&self) -> &'static str {
match self {
ChangeSetColumn::Name => "Name",
ChangeSetColumn::CreatedTime => "Created time",
ChangeSetColumn::Status => "Status",
ChangeSetColumn::Description => "Description",
ChangeSetColumn::RootChangeSetId => "Root change set ID",
ChangeSetColumn::ParentChangeSetId => "Parent change set ID",
}
}
pub fn all() -> Vec<ChangeSetColumn> {
vec![
ChangeSetColumn::Name,
ChangeSetColumn::CreatedTime,
ChangeSetColumn::Status,
ChangeSetColumn::Description,
ChangeSetColumn::RootChangeSetId,
ChangeSetColumn::ParentChangeSetId,
]
}
pub fn from_id(id: &str) -> Option<ChangeSetColumn> {
match id {
Self::ID_NAME => Some(ChangeSetColumn::Name),
Self::ID_CREATED_TIME => Some(ChangeSetColumn::CreatedTime),
Self::ID_STATUS => Some(ChangeSetColumn::Status),
Self::ID_DESCRIPTION => Some(ChangeSetColumn::Description),
Self::ID_ROOT_CHANGE_SET_ID => Some(ChangeSetColumn::RootChangeSetId),
Self::ID_PARENT_CHANGE_SET_ID => Some(ChangeSetColumn::ParentChangeSetId),
_ => None,
}
}
}
impl Column<StackChangeSet> for ChangeSetColumn {
fn id(&self) -> &'static str {
Self::id(self)
}
fn default_name(&self) -> &'static str {
Self::default_name(self)
}
fn width(&self) -> u16 {
let translated = translate_column(self.id(), self.default_name());
translated.len().max(match self {
ChangeSetColumn::Name => 40,
ChangeSetColumn::CreatedTime => UTC_TIMESTAMP_WIDTH as usize,
ChangeSetColumn::Status => 30,
ChangeSetColumn::Description => 50,
ChangeSetColumn::RootChangeSetId => 50,
ChangeSetColumn::ParentChangeSetId => 50,
}) as u16
}
fn render(&self, item: &StackChangeSet) -> (String, Style) {
match self {
ChangeSetColumn::Name => (item.name.clone(), Style::default()),
ChangeSetColumn::CreatedTime => {
(format_iso_timestamp(&item.created_time), Style::default())
}
ChangeSetColumn::Status => {
let (formatted, color) = format_status(&item.status);
(formatted, Style::default().fg(color))
}
ChangeSetColumn::Description => (item.description.clone(), Style::default()),
ChangeSetColumn::RootChangeSetId => (item.root_change_set_id.clone(), Style::default()),
ChangeSetColumn::ParentChangeSetId => {
(item.parent_change_set_id.clone(), Style::default())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum TagColumn {
Key,
Value,
}
impl Column<(String, String)> for TagColumn {
fn name(&self) -> &str {
match self {
TagColumn::Key => "Key",
TagColumn::Value => "Value",
}
}
fn width(&self) -> u16 {
match self {
TagColumn::Key => 40,
TagColumn::Value => 60,
}
}
fn render(&self, item: &(String, String)) -> (String, Style) {
match self {
TagColumn::Key => (item.0.clone(), Style::default()),
TagColumn::Value => (item.1.clone(), Style::default()),
}
}
}
#[cfg(test)]
mod parameter_tests {
use super::*;
use crate::app::App;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
#[test]
fn test_filtered_parameters_empty_filter() {
let mut app = test_app();
app.cfn_state.parameters.items = vec![
StackParameter {
key: "Param1".to_string(),
value: "Value1".to_string(),
resolved_value: "Resolved1".to_string(),
},
StackParameter {
key: "Param2".to_string(),
value: "Value2".to_string(),
resolved_value: "Resolved2".to_string(),
},
];
app.cfn_state.parameters.filter = String::new();
let filtered = filtered_parameters(&app);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filtered_parameters_by_key() {
let mut app = test_app();
app.cfn_state.parameters.items = vec![
StackParameter {
key: "DatabaseName".to_string(),
value: "mydb".to_string(),
resolved_value: "mydb".to_string(),
},
StackParameter {
key: "InstanceType".to_string(),
value: "t2.micro".to_string(),
resolved_value: "t2.micro".to_string(),
},
];
app.cfn_state.parameters.filter = "database".to_string();
let filtered = filtered_parameters(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].key, "DatabaseName");
}
#[test]
fn test_filtered_parameters_by_value() {
let mut app = test_app();
app.cfn_state.parameters.items = vec![
StackParameter {
key: "Param1".to_string(),
value: "production".to_string(),
resolved_value: "production".to_string(),
},
StackParameter {
key: "Param2".to_string(),
value: "staging".to_string(),
resolved_value: "staging".to_string(),
},
];
app.cfn_state.parameters.filter = "prod".to_string();
let filtered = filtered_parameters(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].value, "production");
}
#[test]
fn test_parameters_state_initialization() {
let state = State::new();
assert_eq!(state.parameters.items.len(), 0);
assert_eq!(state.parameters.selected, 0);
assert_eq!(state.parameters.filter, "");
assert_eq!(state.parameters_input_focus, InputFocus::Filter);
}
#[test]
fn test_parameters_expansion() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::CloudFormationStacks;
app.cfn_state.current_stack = Some("test-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Parameters;
app.cfn_state.parameters.items = vec![StackParameter {
key: "Param1".to_string(),
value: "Value1".to_string(),
resolved_value: "Resolved1".to_string(),
}];
assert_eq!(app.cfn_state.parameters.expanded_item, None);
app.cfn_state.parameters.toggle_expand();
assert_eq!(app.cfn_state.parameters.expanded_item, Some(0));
app.cfn_state.parameters.collapse();
assert_eq!(app.cfn_state.parameters.expanded_item, None);
}
}
#[cfg(test)]
mod output_tests {
use super::*;
use crate::app::App;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
#[test]
fn test_filtered_outputs_empty_filter() {
let mut app = test_app();
app.cfn_state.outputs.items = vec![
StackOutput {
key: "Output1".to_string(),
value: "Value1".to_string(),
description: "Desc1".to_string(),
export_name: "Export1".to_string(),
},
StackOutput {
key: "Output2".to_string(),
value: "Value2".to_string(),
description: "Desc2".to_string(),
export_name: "Export2".to_string(),
},
];
app.cfn_state.outputs.filter = String::new();
let filtered = filtered_outputs(&app);
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filtered_outputs_by_key() {
let mut app = test_app();
app.cfn_state.outputs.items = vec![
StackOutput {
key: "ApiUrl".to_string(),
value: "https://api.example.com".to_string(),
description: "API endpoint".to_string(),
export_name: "MyApiUrl".to_string(),
},
StackOutput {
key: "BucketName".to_string(),
value: "my-bucket".to_string(),
description: "S3 bucket".to_string(),
export_name: "MyBucket".to_string(),
},
];
app.cfn_state.outputs.filter = "api".to_string();
let filtered = filtered_outputs(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].key, "ApiUrl");
}
#[test]
fn test_filtered_outputs_by_value() {
let mut app = test_app();
app.cfn_state.outputs.items = vec![
StackOutput {
key: "ApiUrl".to_string(),
value: "https://api.example.com".to_string(),
description: "API endpoint".to_string(),
export_name: "MyApiUrl".to_string(),
},
StackOutput {
key: "BucketName".to_string(),
value: "my-bucket".to_string(),
description: "S3 bucket".to_string(),
export_name: "MyBucket".to_string(),
},
];
app.cfn_state.outputs.filter = "my-bucket".to_string();
let filtered = filtered_outputs(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].key, "BucketName");
}
#[test]
fn test_outputs_state_initialization() {
let app = test_app();
assert_eq!(app.cfn_state.outputs.items.len(), 0);
assert_eq!(app.cfn_state.outputs.filter, "");
assert_eq!(app.cfn_state.outputs.selected, 0);
assert_eq!(app.cfn_state.outputs.expanded_item, None);
}
#[test]
fn test_outputs_expansion() {
let mut app = test_app();
app.cfn_state.current_stack = Some("test-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Outputs;
app.cfn_state.outputs.items = vec![StackOutput {
key: "Output1".to_string(),
value: "Value1".to_string(),
description: "Desc1".to_string(),
export_name: "Export1".to_string(),
}];
assert_eq!(app.cfn_state.outputs.expanded_item, None);
app.cfn_state.outputs.toggle_expand();
assert_eq!(app.cfn_state.outputs.expanded_item, Some(0));
app.cfn_state.outputs.collapse();
assert_eq!(app.cfn_state.outputs.expanded_item, None);
}
#[test]
fn test_expanded_items_hierarchical_view() {
let mut app = test_app();
assert!(app.cfn_state.expanded_items.is_empty());
app.cfn_state
.expanded_items
.insert("MyNestedStack".to_string());
assert!(app.cfn_state.expanded_items.contains("MyNestedStack"));
app.cfn_state
.expanded_items
.insert("MyNestedStack/ChildResource".to_string());
assert_eq!(app.cfn_state.expanded_items.len(), 2);
app.cfn_state.expanded_items.remove("MyNestedStack");
assert!(!app.cfn_state.expanded_items.contains("MyNestedStack"));
assert_eq!(app.cfn_state.expanded_items.len(), 1);
}
}
#[cfg(test)]
mod resource_tests {
use super::*;
use crate::app::App;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
#[test]
fn test_resources_state_initialization() {
let app = test_app();
assert_eq!(app.cfn_state.resources.items.len(), 0);
assert_eq!(app.cfn_state.resources.filter, "");
assert_eq!(app.cfn_state.resources.selected, 0);
}
#[test]
fn test_filtered_resources_empty_filter() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "MyBucket".to_string(),
physical_id: "my-bucket-123".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "MyFunction".to_string(),
physical_id: "my-function-456".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
app.cfn_state.resources.filter = String::new();
let filtered: Vec<&StackResource> = if app.cfn_state.resources.filter.is_empty() {
app.cfn_state.resources.items.iter().collect()
} else {
app.cfn_state
.resources
.items
.iter()
.filter(|r| {
r.logical_id
.to_lowercase()
.contains(&app.cfn_state.resources.filter.to_lowercase())
})
.collect()
};
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filtered_resources_by_logical_id() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "MyBucket".to_string(),
physical_id: "my-bucket-123".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "MyFunction".to_string(),
physical_id: "my-function-456".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
app.cfn_state.resources.filter = "bucket".to_string();
let filtered: Vec<&StackResource> = app
.cfn_state
.resources
.items
.iter()
.filter(|r| {
r.logical_id
.to_lowercase()
.contains(&app.cfn_state.resources.filter.to_lowercase())
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].logical_id, "MyBucket");
}
#[test]
fn test_filtered_resources_by_type() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "MyBucket".to_string(),
physical_id: "my-bucket-123".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "MyFunction".to_string(),
physical_id: "my-function-456".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
app.cfn_state.resources.filter = "lambda".to_string();
let filtered: Vec<&StackResource> = app
.cfn_state
.resources
.items
.iter()
.filter(|r| {
r.resource_type
.to_lowercase()
.contains(&app.cfn_state.resources.filter.to_lowercase())
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].logical_id, "MyFunction");
}
#[test]
fn test_resources_sorted_by_logical_id() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "ZBucket".to_string(),
physical_id: "z-bucket".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "AFunction".to_string(),
physical_id: "a-function".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
assert_eq!(app.cfn_state.resources.items[0].logical_id, "ZBucket");
assert_eq!(app.cfn_state.resources.items[1].logical_id, "AFunction");
}
#[test]
fn test_resources_expansion() {
let mut app = test_app();
app.cfn_state.resources.items = vec![StackResource {
logical_id: "MyBucket".to_string(),
physical_id: "my-bucket-123".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
}];
assert_eq!(app.cfn_state.resources.expanded_item, None);
app.cfn_state.resources.toggle_expand();
assert_eq!(app.cfn_state.resources.expanded_item, Some(0));
app.cfn_state.resources.collapse();
assert_eq!(app.cfn_state.resources.expanded_item, None);
}
#[test]
fn test_resource_column_ids() {
let ids = resource_column_ids();
assert_eq!(ids.len(), 5);
assert!(ids.contains(&"cfn.resource.logical_id"));
assert!(ids.contains(&"cfn.resource.physical_id"));
assert!(ids.contains(&"cfn.resource.type"));
assert!(ids.contains(&"cfn.resource.status"));
assert!(ids.contains(&"cfn.resource.module"));
}
#[test]
fn test_detail_tab_allows_preferences() {
assert!(DetailTab::StackInfo.allows_preferences());
assert!(DetailTab::Parameters.allows_preferences());
assert!(DetailTab::Outputs.allows_preferences());
assert!(DetailTab::Resources.allows_preferences());
assert!(DetailTab::Events.allows_preferences());
assert!(DetailTab::ChangeSets.allows_preferences());
assert!(!DetailTab::Template.allows_preferences());
assert!(!DetailTab::GitSync.allows_preferences());
}
#[test]
fn test_resources_tree_view_expansion() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "ParentModule".to_string(),
physical_id: "parent-123".to_string(),
resource_type: "AWS::CloudFormation::Stack".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "ChildResource".to_string(),
physical_id: "child-456".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: "ParentModule".to_string(),
},
];
assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
app.cfn_state
.expanded_items
.insert("ParentModule".to_string());
assert!(app.cfn_state.expanded_items.contains("ParentModule"));
app.cfn_state.expanded_items.remove("ParentModule");
assert!(!app.cfn_state.expanded_items.contains("ParentModule"));
}
#[test]
fn test_resources_navigation() {
use crate::app::Service;
let mut app = test_app();
app.current_service = Service::CloudFormationStacks;
app.cfn_state.current_stack = Some("test-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Resources;
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "Resource1".to_string(),
physical_id: "res-1".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "Resource2".to_string(),
physical_id: "res-2".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
assert_eq!(app.cfn_state.resources.selected, 0);
let filtered = filtered_resources(&app);
app.cfn_state.resources.next_item(filtered.len());
assert_eq!(app.cfn_state.resources.selected, 1);
app.cfn_state.resources.prev_item();
assert_eq!(app.cfn_state.resources.selected, 0);
}
#[test]
fn test_resources_filter() {
let mut app = test_app();
app.cfn_state.resources.items = vec![
StackResource {
logical_id: "MyBucket".to_string(),
physical_id: "my-bucket-123".to_string(),
resource_type: "AWS::S3::Bucket".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
StackResource {
logical_id: "MyFunction".to_string(),
physical_id: "my-function-456".to_string(),
resource_type: "AWS::Lambda::Function".to_string(),
status: "CREATE_COMPLETE".to_string(),
module_info: String::new(),
},
];
app.cfn_state.resources.filter = String::new();
let filtered = filtered_resources(&app);
assert_eq!(filtered.len(), 2);
app.cfn_state.resources.filter = "bucket".to_string();
let filtered = filtered_resources(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].logical_id, "MyBucket");
app.cfn_state.resources.filter = "lambda".to_string();
let filtered = filtered_resources(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].logical_id, "MyFunction");
}
#[test]
fn test_resources_page_size() {
use crate::common::PageSize;
let mut app = test_app();
assert_eq!(app.cfn_state.resources.page_size, PageSize::Fifty);
assert_eq!(app.cfn_state.resources.page_size.value(), 50);
app.cfn_state.resources.page_size = PageSize::TwentyFive;
assert_eq!(app.cfn_state.resources.page_size, PageSize::TwentyFive);
assert_eq!(app.cfn_state.resources.page_size.value(), 25);
}
}
#[cfg(test)]
mod events_tests {
use super::*;
use crate::app::App;
use crate::ui::table::Column;
use ratatui::style::Color;
use rusticity_core::cfn::StackEvent;
fn test_app() -> App {
App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
}
fn make_event(status: &str) -> StackEvent {
StackEvent {
event_id: "evt-001".to_string(),
timestamp: "2024-01-01T12:00:00Z".to_string(),
logical_id: "MyBucket".to_string(),
status: status.to_string(),
detailed_status: String::new(),
status_reason: "Resource creation complete".to_string(),
hook_invocation_count: String::new(),
resource_type: "AWS::S3::Bucket".to_string(),
physical_id: "my-bucket-123".to_string(),
client_request_token: "token-abc".to_string(),
operation_id: String::new(),
}
}
#[test]
fn test_event_status_column_uses_format_status() {
let col = EventColumn::Status;
let e = make_event("CREATE_COMPLETE");
let (text, style) = col.render(&e);
assert!(
text.contains("✅"),
"CREATE_COMPLETE should have ✅, got: {text}"
);
assert_eq!(style.fg, Some(Color::Green));
let e = make_event("CREATE_FAILED");
let (text, style) = col.render(&e);
assert!(
text.contains("❌"),
"CREATE_FAILED should have ❌, got: {text}"
);
assert_eq!(style.fg, Some(Color::Red));
let e = make_event("UPDATE_IN_PROGRESS");
let (text, style) = col.render(&e);
assert!(
text.contains("ℹ️"),
"UPDATE_IN_PROGRESS should have ℹ️, got: {text}"
);
assert_eq!(style.fg, Some(Color::Blue));
}
#[test]
fn test_event_default_visible_columns_excludes_non_default() {
let ids = event_visible_column_ids();
let all_ids = event_column_ids();
assert!(
ids.contains(&EventColumn::OperationId.id()),
"OperationId must be visible by default"
);
assert!(
!ids.contains(&EventColumn::PhysicalId.id()),
"PhysicalId should not be visible by default"
);
assert!(
!ids.contains(&EventColumn::ClientRequestToken.id()),
"ClientRequestToken should not be visible by default"
);
assert!(
!ids.contains(&EventColumn::HookInvocationCount.id()),
"HookInvocationCount should not be visible by default"
);
assert!(
!ids.contains(&EventColumn::Type.id()),
"Type should not be visible by default"
);
assert!(
ids.contains(&EventColumn::Timestamp.id()),
"Timestamp must be visible by default"
);
assert!(
ids.contains(&EventColumn::LogicalId.id()),
"LogicalId must be visible by default"
);
assert!(
ids.contains(&EventColumn::Status.id()),
"Status must be visible by default"
);
assert!(
ids.contains(&EventColumn::DetailedStatus.id()),
"DetailedStatus must be visible by default"
);
assert!(
ids.contains(&EventColumn::StatusReason.id()),
"StatusReason must be visible by default"
);
assert_eq!(all_ids.len(), EventColumn::all().len());
}
#[test]
fn test_event_column_ids_roundtrip() {
for col in EventColumn::all() {
let id = col.id();
let roundtrip = EventColumn::from_id(id);
assert_eq!(roundtrip, Some(col), "from_id({id}) should return {col:?}");
}
}
#[test]
fn test_event_column_sorted_by_timestamp_desc() {
assert_eq!(EventColumn::Timestamp.default_name(), "Timestamp");
}
#[test]
fn test_events_view_default_is_table() {
let app = test_app();
assert_eq!(
app.cfn_state.events_view,
EventsView::Table,
"Default events view must be Table"
);
}
#[test]
fn test_toggle_events_view() {
let mut app = test_app();
app.current_service = crate::app::Service::CloudFormationStacks;
app.cfn_state.current_stack = Some("my-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Events;
assert_eq!(app.cfn_state.events_view, EventsView::Table);
crate::cfn::actions::toggle_events_view(&mut app);
assert_eq!(app.cfn_state.events_view, EventsView::Timeline);
crate::cfn::actions::toggle_events_view(&mut app);
assert_eq!(app.cfn_state.events_view, EventsView::Table);
}
#[test]
fn test_filtered_events_by_logical_id() {
let mut app = test_app();
app.cfn_state.events.items = vec![make_event("CREATE_COMPLETE"), {
let mut e = make_event("DELETE_IN_PROGRESS");
e.logical_id = "OtherResource".to_string();
e
}];
app.cfn_state.events.filter = "MyBucket".to_string();
let filtered = filtered_events(&app);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].logical_id, "MyBucket");
}
#[test]
fn test_column_selector_max_events_tab() {
let mut app = test_app();
app.cfn_state.current_stack = Some("my-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Events;
let max = crate::cfn::actions::column_selector_max(&app);
let count = crate::cfn::actions::column_count(&app);
assert_eq!(count, EventColumn::all().len());
assert_eq!(max, count + 6);
}
#[test]
fn test_events_allows_preferences() {
assert!(
DetailTab::Events.allows_preferences(),
"Events tab must allow preferences (column selector)"
);
}
#[test]
fn test_events_expand_collapse() {
let mut app = test_app();
app.current_service = crate::app::Service::CloudFormationStacks;
app.service_selected = true;
app.cfn_state.current_stack = Some("my-stack".to_string());
app.cfn_state.detail_tab = DetailTab::Events;
app.cfn_state.events.items = vec![make_event("CREATE_COMPLETE")];
app.cfn_state.events.selected = 0;
assert_eq!(app.cfn_state.events.expanded_item, None, "starts collapsed");
app.handle_action(crate::keymap::Action::ExpandRow);
assert_eq!(
app.cfn_state.events.expanded_item,
Some(0),
"Enter must expand the selected event row"
);
app.handle_action(crate::keymap::Action::CollapseRow);
assert_eq!(
app.cfn_state.events.expanded_item, None,
"Left arrow must collapse the expanded row"
);
}
#[test]
fn test_events_column_selector_shows_all_columns() {
let ids = event_column_ids();
assert_eq!(
ids.len(),
EventColumn::all().len(),
"event_column_ids must return all {} columns",
EventColumn::all().len()
);
for id in &ids {
assert!(
EventColumn::from_id(id).is_some(),
"EventColumn::from_id({id}) must succeed"
);
}
}
#[test]
fn test_events_timestamp_uses_utc_format() {
let col = EventColumn::Timestamp;
let e = StackEvent {
event_id: "evt".into(),
timestamp: "2024-01-15T12:30:45Z".into(),
logical_id: "R".into(),
status: "CREATE_COMPLETE".into(),
detailed_status: String::new(),
status_reason: String::new(),
hook_invocation_count: String::new(),
resource_type: String::new(),
physical_id: String::new(),
client_request_token: String::new(),
operation_id: String::new(),
};
let (text, _) = col.render(&e);
assert!(
text.contains("(UTC)"),
"Timestamp must be formatted with (UTC), got: {text}"
);
assert!(
text.contains("2024-01-15"),
"Timestamp must contain the date, got: {text}"
);
assert!(
text.contains("12:30:45"),
"Timestamp must contain the time, got: {text}"
);
}
#[test]
fn test_events_timestamp_column_width_is_utc_width() {
use crate::common::UTC_TIMESTAMP_WIDTH;
let col = EventColumn::Timestamp;
assert_eq!(
col.width(),
UTC_TIMESTAMP_WIDTH,
"Timestamp column width must equal UTC_TIMESTAMP_WIDTH ({UTC_TIMESTAMP_WIDTH})"
);
}
}