1pub mod cfn;
2pub mod cw;
3pub mod ecr;
4mod expanded_view;
5pub mod filter;
6pub mod iam;
7pub mod lambda;
8mod pagination;
9pub mod prefs;
10mod query_editor;
11pub mod s3;
12pub mod sqs;
13mod status;
14pub mod styles;
15pub mod table;
16
17use crate::cfn::Column as CfnColumn;
18use crate::cw::alarms::AlarmColumn;
19use crate::ecr::{image, repo};
20use crate::lambda::{DeploymentColumn, ResourceColumn};
21use crate::sqs::queue::Column as SqsColumn;
22use crate::sqs::trigger::Column as SqsTriggerColumn;
23use crate::ui::table::Column as TableColumn;
24use styles::highlight;
25
26pub use cw::insights::{DateRangeType, TimeUnit};
27pub use cw::{
28 CloudWatchLogGroupsState, DetailTab, EventColumn, EventFilterFocus, LogGroupColumn,
29 StreamColumn, StreamSort,
30};
31pub use expanded_view::{format_expansion_text, format_fields};
32pub use pagination::{render_paginated_filter, PaginatedFilterConfig};
33pub use prefs::Preferences;
34pub use query_editor::{render_query_editor, QueryEditorConfig};
35pub use status::{first_hint, hint, last_hint, SPINNER_FRAMES};
36pub use table::{format_expandable, CURSOR_COLLAPSED, CURSOR_EXPANDED};
37
38use crate::app::{AlarmViewMode, App, Service, ViewMode};
39use crate::common::{render_pagination_text, PageSize};
40use crate::keymap::Mode;
41use ratatui::style::{Modifier, Style};
42use ratatui::text::{Line, Span};
43
44pub fn labeled_field(label: &str, value: impl Into<String>) -> Line<'static> {
45 let val = value.into();
46 let display = if val.is_empty() { "-".to_string() } else { val };
47 Line::from(vec![
48 Span::styled(
49 format!("{}: ", label),
50 Style::default().add_modifier(Modifier::BOLD),
51 ),
52 Span::raw(display),
53 ])
54}
55
56pub fn section_header(text: &str, width: u16) -> Line<'static> {
57 let text_len = text.len() as u16;
58 let remaining = width.saturating_sub(text_len + 2);
61 let dashes = "─".repeat(remaining as usize);
62 Line::from(vec![
63 Span::raw(" "),
64 Span::raw(text.to_string()),
65 Span::raw(format!(" {}", dashes)),
66 ])
67}
68
69pub fn tab_style(selected: bool) -> Style {
70 if selected {
71 highlight()
72 } else {
73 Style::default()
74 }
75}
76
77pub fn service_tab_style(selected: bool) -> Style {
78 if selected {
79 Style::default().bg(Color::Green).fg(Color::Black)
80 } else {
81 Style::default()
82 }
83}
84
85pub fn render_tab_spans<'a>(tabs: &[(&'a str, bool)]) -> Vec<Span<'a>> {
86 let mut spans = Vec::new();
87 for (i, (name, selected)) in tabs.iter().enumerate() {
88 if i > 0 {
89 spans.push(Span::raw(" ⋮ "));
90 }
91 spans.push(Span::styled(*name, service_tab_style(*selected)));
92 }
93 spans
94}
95
96use ratatui::{prelude::*, widgets::*};
97
98pub const SEARCH_ICON: &str = " 🔍 ";
100pub const PREFERENCES_TITLE: &str = " Preferences ";
101
102pub fn filter_area(filter_text: Vec<Span<'_>>, is_active: bool) -> Paragraph<'_> {
104 Paragraph::new(Line::from(filter_text))
105 .block(
106 Block::default()
107 .title(SEARCH_ICON)
108 .borders(Borders::ALL)
109 .border_type(BorderType::Rounded)
110 .border_type(BorderType::Rounded)
111 .border_type(BorderType::Rounded)
112 .border_style(if is_active {
113 active_border()
114 } else {
115 Style::default()
116 }),
117 )
118 .style(Style::default())
119}
120
121pub fn active_border() -> Style {
123 Style::default().fg(Color::Green)
124}
125
126pub fn rounded_block() -> Block<'static> {
127 Block::default()
128 .borders(Borders::ALL)
129 .border_type(BorderType::Rounded)
130 .border_type(BorderType::Rounded)
131 .border_type(BorderType::Rounded)
132}
133
134pub fn titled_rounded_block(title: &'static str) -> Block<'static> {
135 rounded_block().title(title)
136}
137
138pub fn bold_style() -> Style {
139 Style::default().add_modifier(Modifier::BOLD)
140}
141
142pub fn cyan_bold() -> Style {
143 Style::default()
144 .fg(Color::Cyan)
145 .add_modifier(Modifier::BOLD)
146}
147
148pub fn red_text() -> Style {
149 Style::default().fg(Color::Rgb(255, 165, 0))
150}
151
152pub fn yellow_text() -> Style {
153 Style::default().fg(Color::Yellow)
154}
155
156pub fn get_cursor(active: bool) -> &'static str {
157 if active {
158 "█"
159 } else {
160 ""
161 }
162}
163
164pub fn render_search_filter(
165 frame: &mut Frame,
166 area: Rect,
167 filter_text: &str,
168 is_active: bool,
169 selected: usize,
170 total_items: usize,
171 page_size: usize,
172) {
173 let cursor = get_cursor(is_active);
174 let total_pages = total_items.div_ceil(page_size);
175 let current_page = selected / page_size;
176 let pagination = render_pagination_text(current_page, total_pages);
177
178 let controls_text = format!(" {}", pagination);
179 let filter_width = (area.width as usize).saturating_sub(4);
180 let content_len = filter_text.len() + if is_active { cursor.len() } else { 0 };
181 let available_space = filter_width.saturating_sub(controls_text.len() + 1);
182
183 let mut spans = vec![];
184 if filter_text.is_empty() && !is_active {
185 spans.push(Span::styled("Search", Style::default().fg(Color::DarkGray)));
186 } else {
187 spans.push(Span::raw(filter_text));
188 }
189 if is_active {
190 spans.push(Span::styled(cursor, Style::default().fg(Color::Yellow)));
191 }
192 if content_len < available_space {
193 spans.push(Span::raw(
194 " ".repeat(available_space.saturating_sub(content_len)),
195 ));
196 }
197 spans.push(Span::styled(
198 controls_text,
199 if is_active {
200 Style::default()
201 } else {
202 Style::default().fg(Color::Green)
203 },
204 ));
205
206 let filter = filter_area(spans, is_active);
207 frame.render_widget(filter, area);
208}
209
210fn render_toggle(is_on: bool) -> Vec<Span<'static>> {
211 if is_on {
212 vec![
213 Span::styled("◼", Style::default().fg(Color::Blue)),
214 Span::raw("⬜"),
215 ]
216 } else {
217 vec![
218 Span::raw("⬜"),
219 Span::styled("◼", Style::default().fg(Color::Black)),
220 ]
221 }
222}
223
224fn render_radio(is_selected: bool) -> (String, Style) {
225 if is_selected {
226 ("●".to_string(), Style::default().fg(Color::Blue))
227 } else {
228 ("○".to_string(), Style::default())
229 }
230}
231
232pub fn vertical(
236 constraints: impl IntoIterator<Item = Constraint>,
237 area: Rect,
238) -> std::rc::Rc<[Rect]> {
239 Layout::default()
240 .direction(Direction::Vertical)
241 .constraints(constraints)
242 .split(area)
243}
244
245pub fn horizontal(
246 constraints: impl IntoIterator<Item = Constraint>,
247 area: Rect,
248) -> std::rc::Rc<[Rect]> {
249 Layout::default()
250 .direction(Direction::Horizontal)
251 .constraints(constraints)
252 .split(area)
253}
254
255pub fn block(title: &str) -> Block<'_> {
257 crate::ui::rounded_block().title(title)
258}
259
260pub fn block_with_style(title: &str, style: Style) -> Block<'_> {
261 block(title).border_style(style)
262}
263
264pub fn render_summary(frame: &mut Frame, area: Rect, title: &str, fields: &[(&str, String)]) {
266 let summary_block = block(title).border_type(BorderType::Rounded);
267 let inner = summary_block.inner(area);
268 frame.render_widget(summary_block, area);
269
270 let lines: Vec<Line> = fields
271 .iter()
272 .map(|(label, value)| {
273 Line::from(vec![
274 Span::styled(*label, Style::default().add_modifier(Modifier::BOLD)),
275 Span::raw(value),
276 ])
277 })
278 .collect();
279
280 frame.render_widget(Paragraph::new(lines), inner);
281}
282
283pub fn render_tabs<T: PartialEq>(frame: &mut Frame, area: Rect, tabs: &[(&str, T)], selected: &T) {
285 let spans: Vec<Span> = tabs
286 .iter()
287 .enumerate()
288 .flat_map(|(i, (name, tab))| {
289 let mut result = Vec::new();
290 if i > 0 {
291 result.push(Span::raw(" ⋮ "));
292 }
293 if tab == selected {
294 result.push(Span::styled(*name, tab_style(true)));
295 } else {
296 result.push(Span::raw(*name));
297 }
298 result
299 })
300 .collect();
301
302 frame.render_widget(Paragraph::new(Line::from(spans)), area);
303}
304
305pub fn format_duration(seconds: u64) -> String {
306 const MINUTE: u64 = 60;
307 const HOUR: u64 = 60 * MINUTE;
308 const DAY: u64 = 24 * HOUR;
309 const WEEK: u64 = 7 * DAY;
310 const YEAR: u64 = 365 * DAY;
311
312 if seconds >= YEAR {
313 let years = seconds / YEAR;
314 let remainder = seconds % YEAR;
315 if remainder == 0 {
316 format!("{} year{}", years, if years == 1 { "" } else { "s" })
317 } else {
318 let weeks = remainder / WEEK;
319 format!(
320 "{} year{} {} week{}",
321 years,
322 if years == 1 { "" } else { "s" },
323 weeks,
324 if weeks == 1 { "" } else { "s" }
325 )
326 }
327 } else if seconds >= WEEK {
328 let weeks = seconds / WEEK;
329 let remainder = seconds % WEEK;
330 if remainder == 0 {
331 format!("{} week{}", weeks, if weeks == 1 { "" } else { "s" })
332 } else {
333 let days = remainder / DAY;
334 format!(
335 "{} week{} {} day{}",
336 weeks,
337 if weeks == 1 { "" } else { "s" },
338 days,
339 if days == 1 { "" } else { "s" }
340 )
341 }
342 } else if seconds >= DAY {
343 let days = seconds / DAY;
344 let remainder = seconds % DAY;
345 if remainder == 0 {
346 format!("{} day{}", days, if days == 1 { "" } else { "s" })
347 } else {
348 let hours = remainder / HOUR;
349 format!(
350 "{} day{} {} hour{}",
351 days,
352 if days == 1 { "" } else { "s" },
353 hours,
354 if hours == 1 { "" } else { "s" }
355 )
356 }
357 } else if seconds >= HOUR {
358 let hours = seconds / HOUR;
359 let remainder = seconds % HOUR;
360 if remainder == 0 {
361 format!("{} hour{}", hours, if hours == 1 { "" } else { "s" })
362 } else {
363 let minutes = remainder / MINUTE;
364 format!(
365 "{} hour{} {} minute{}",
366 hours,
367 if hours == 1 { "" } else { "s" },
368 minutes,
369 if minutes == 1 { "" } else { "s" }
370 )
371 }
372 } else if seconds >= MINUTE {
373 let minutes = seconds / MINUTE;
374 format!("{} minute{}", minutes, if minutes == 1 { "" } else { "s" })
375 } else {
376 format!("{} second{}", seconds, if seconds == 1 { "" } else { "s" })
377 }
378}
379
380fn render_column_toggle_string(col_name: &str, is_visible: bool) -> (ListItem<'static>, usize) {
381 let mut spans = vec![];
382 spans.extend(render_toggle(is_visible));
383 spans.push(Span::raw(" "));
384 spans.push(Span::raw(col_name.to_string()));
385 let text_len = 4 + col_name.len();
386 (ListItem::new(Line::from(spans)), text_len)
387}
388
389fn render_section_header(title: &str) -> (ListItem<'static>, usize) {
391 let len = title.len();
392 (
393 ListItem::new(Line::from(Span::styled(
394 title.to_string(),
395 Style::default()
396 .fg(Color::Cyan)
397 .add_modifier(Modifier::BOLD),
398 ))),
399 len,
400 )
401}
402
403fn render_radio_item(label: &str, is_selected: bool, indent: bool) -> (ListItem<'static>, usize) {
405 let (radio, style) = render_radio(is_selected);
406 let text_len = (if indent { 2 } else { 0 }) + radio.chars().count() + 1 + label.len();
407 let mut spans = if indent {
408 vec![Span::raw(" ")]
409 } else {
410 vec![]
411 };
412 spans.push(Span::styled(radio, style));
413 spans.push(Span::raw(format!(" {}", label)));
414 (ListItem::new(Line::from(spans)), text_len)
415}
416
417fn render_page_size_section(
419 current_size: PageSize,
420 sizes: &[(PageSize, &str)],
421) -> (Vec<ListItem<'static>>, usize) {
422 let mut items = Vec::new();
423 let mut max_len = 0;
424
425 let (header, header_len) = render_section_header("Page size");
426 items.push(header);
427 max_len = max_len.max(header_len);
428
429 for (size, label) in sizes {
430 let is_selected = current_size == *size;
431 let (item, len) = render_radio_item(label, is_selected, false);
432 items.push(item);
433 max_len = max_len.max(len);
434 }
435
436 (items, max_len)
437}
438
439pub fn render(frame: &mut Frame, app: &App) {
440 let area = frame.area();
441
442 let has_tabs = !app.tabs.is_empty();
444 let show_breadcrumbs = has_tabs && app.service_selected && {
445 match app.current_service {
447 Service::CloudWatchLogGroups => app.view_mode != ViewMode::List,
448 Service::S3Buckets => app.s3_state.current_bucket.is_some(),
449 _ => false,
450 }
451 };
452
453 let chunks = if show_breadcrumbs {
454 Layout::default()
455 .direction(Direction::Vertical)
456 .constraints([
457 Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
462 .split(area)
463 } else {
464 Layout::default()
465 .direction(Direction::Vertical)
466 .constraints([
467 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
471 .split(area)
472 };
473
474 render_tabs_row(frame, app, chunks[0]);
476
477 if show_breadcrumbs {
478 render_top_bar(frame, app, chunks[1]);
479 }
480
481 let content_idx = if show_breadcrumbs { 2 } else { 1 };
482 let bottom_idx = if show_breadcrumbs { 3 } else { 2 };
483
484 if !app.service_selected && app.tabs.is_empty() && app.mode == Mode::Normal {
485 let message = vec![
487 Line::from(""),
488 Line::from(""),
489 Line::from(vec![
490 Span::raw("Press "),
491 Span::styled("␣", Style::default().fg(Color::Red)),
492 Span::raw(" to open Menu"),
493 ]),
494 ];
495 let paragraph = Paragraph::new(message).alignment(Alignment::Center);
496 frame.render_widget(paragraph, chunks[content_idx]);
497 render_bottom_bar(frame, app, chunks[bottom_idx]);
498 } else if !app.service_selected && app.mode == Mode::Normal {
499 render_service_picker(frame, app, chunks[content_idx]);
500 render_bottom_bar(frame, app, chunks[bottom_idx]);
501 } else if app.service_selected {
502 render_service(frame, app, chunks[content_idx]);
503 render_bottom_bar(frame, app, chunks[bottom_idx]);
504 } else {
505 render_bottom_bar(frame, app, chunks[bottom_idx]);
507 }
508
509 match app.mode {
511 Mode::SpaceMenu => render_space_menu(frame, area),
512 Mode::ServicePicker => render_service_picker(frame, app, area),
513 Mode::ColumnSelector => render_column_selector(frame, app, area),
514 Mode::ErrorModal => render_error_modal(frame, app, area),
515 Mode::HelpModal => render_help_modal(frame, area),
516 Mode::RegionPicker => render_region_selector(frame, app, area),
517 Mode::ProfilePicker => render_profile_picker(frame, app, area),
518 Mode::CalendarPicker => render_calendar_picker(frame, app, area),
519 Mode::TabPicker => render_tab_picker(frame, app, area),
520 Mode::SessionPicker => render_session_picker(frame, app, area),
521 _ => {}
522 }
523}
524
525fn render_tabs_row(frame: &mut Frame, app: &App, area: Rect) {
526 let chunks = Layout::default()
528 .direction(Direction::Vertical)
529 .constraints([Constraint::Length(1), Constraint::Length(1)])
530 .split(area);
531
532 let now = chrono::Utc::now();
534 let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();
535
536 let (identity_label, identity_value) = if app.config.role_arn.is_empty() {
537 ("Identity:", "N/A".to_string())
538 } else if let Some(role_part) = app.config.role_arn.split("assumed-role/").nth(1) {
539 (
540 "Role:",
541 role_part.split('/').next().unwrap_or("N/A").to_string(),
542 )
543 } else if let Some(user_part) = app.config.role_arn.split(":user/").nth(1) {
544 ("User:", user_part.to_string())
545 } else {
546 ("Identity:", "N/A".to_string())
547 };
548
549 let region_display = if app.config.region_auto_detected {
550 format!(" {} ⚡ ⋮ ", app.config.region)
551 } else {
552 format!(" {} ⋮ ", app.config.region)
553 };
554
555 let info_spans = vec![
556 Span::styled(
557 "Profile:",
558 Style::default()
559 .fg(Color::White)
560 .add_modifier(Modifier::BOLD),
561 ),
562 Span::styled(
563 format!(" {} ⋮ ", app.profile),
564 Style::default().fg(Color::White),
565 ),
566 Span::styled(
567 "Account:",
568 Style::default()
569 .fg(Color::White)
570 .add_modifier(Modifier::BOLD),
571 ),
572 Span::styled(
573 format!(" {} ⋮ ", app.config.account_id),
574 Style::default().fg(Color::White),
575 ),
576 Span::styled(
577 "Region:",
578 Style::default()
579 .fg(Color::White)
580 .add_modifier(Modifier::BOLD),
581 ),
582 Span::styled(region_display, Style::default().fg(Color::White)),
583 Span::styled(
584 identity_label,
585 Style::default()
586 .fg(Color::White)
587 .add_modifier(Modifier::BOLD),
588 ),
589 Span::styled(
590 format!(" {} ⋮ ", identity_value),
591 Style::default().fg(Color::White),
592 ),
593 Span::styled(
594 "Timestamp:",
595 Style::default()
596 .fg(Color::White)
597 .add_modifier(Modifier::BOLD),
598 ),
599 Span::styled(
600 format!(" {} (UTC)", timestamp),
601 Style::default().fg(Color::White),
602 ),
603 ];
604
605 let info_widget = Paragraph::new(Line::from(info_spans))
606 .alignment(Alignment::Right)
607 .style(Style::default().bg(Color::DarkGray).fg(Color::White));
608 frame.render_widget(info_widget, chunks[0]);
609
610 let tab_data: Vec<(&str, bool)> = app
612 .tabs
613 .iter()
614 .enumerate()
615 .map(|(i, tab)| (tab.title.as_str(), i == app.current_tab))
616 .collect();
617 let spans = render_tab_spans(&tab_data);
618
619 let tabs_widget = Paragraph::new(Line::from(spans));
620 frame.render_widget(tabs_widget, chunks[1]);
621}
622
623fn render_top_bar(frame: &mut Frame, app: &App, area: Rect) {
624 let breadcrumbs_str = app.breadcrumbs();
625
626 let breadcrumb_line = if app.current_service == Service::S3Buckets
628 && app.s3_state.current_bucket.is_some()
629 && !app.s3_state.prefix_stack.is_empty()
630 {
631 let parts: Vec<&str> = breadcrumbs_str.split(" > ").collect();
632 let mut spans = Vec::new();
633 for (i, part) in parts.iter().enumerate() {
634 if i > 0 {
635 spans.push(Span::raw(" > "));
636 }
637 if i == parts.len() - 1 {
638 spans.push(Span::styled(
640 *part,
641 Style::default()
642 .fg(Color::Cyan)
643 .add_modifier(Modifier::BOLD),
644 ));
645 } else {
646 spans.push(Span::raw(*part));
647 }
648 }
649 Line::from(spans)
650 } else {
651 Line::from(breadcrumbs_str)
652 };
653
654 let breadcrumb_widget =
655 Paragraph::new(breadcrumb_line).style(Style::default().fg(Color::White));
656
657 frame.render_widget(breadcrumb_widget, area);
658}
659fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
660 status::render_bottom_bar(frame, app, area);
661}
662
663fn render_service(frame: &mut Frame, app: &App, area: Rect) {
664 match app.current_service {
665 Service::CloudWatchLogGroups => {
666 if app.view_mode == ViewMode::Events {
667 cw::logs::render_events(frame, app, area);
668 } else if app.view_mode == ViewMode::Detail {
669 cw::logs::render_group_detail(frame, app, area);
670 } else {
671 cw::logs::render_groups_list(frame, app, area);
672 }
673 }
674 Service::CloudWatchInsights => cw::render_insights(frame, app, area),
675 Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
676 Service::EcrRepositories => ecr::render_repositories(frame, app, area),
677 Service::LambdaFunctions => lambda::render_functions(frame, app, area),
678 Service::LambdaApplications => lambda::render_applications(frame, app, area),
679 Service::S3Buckets => s3::render_buckets(frame, app, area),
680 Service::SqsQueues => sqs::render_queues(frame, app, area),
681 Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
682 Service::IamUsers => iam::render_users(frame, app, area),
683 Service::IamRoles => iam::render_roles(frame, app, area),
684 Service::IamUserGroups => iam::render_user_groups(frame, app, area),
685 }
686}
687
688fn render_column_selector(frame: &mut Frame, app: &App, area: Rect) {
689 let (items, title, max_text_len) = if app.current_service == Service::S3Buckets
690 && app.s3_state.current_bucket.is_none()
691 {
692 let mut max_len = 0;
693 let items: Vec<ListItem> = app
694 .s3_bucket_column_ids
695 .iter()
696 .filter_map(|col_id| {
697 crate::s3::BucketColumn::from_id(col_id).map(|col| {
698 let is_visible = app.s3_bucket_visible_column_ids.contains(col_id);
699 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
700 max_len = max_len.max(len);
701 item
702 })
703 })
704 .collect();
705 (items, " Preferences ", max_len)
706 } else if app.current_service == Service::CloudWatchAlarms {
707 let mut all_items: Vec<ListItem> = Vec::new();
708 let mut max_len = 0;
709
710 let (header, header_len) = render_section_header("Columns");
712 all_items.push(header);
713 max_len = max_len.max(header_len);
714
715 for col_id in &app.cw_alarm_column_ids {
716 let is_visible = app.cw_alarm_visible_column_ids.contains(col_id);
717 if let Some(col) = AlarmColumn::from_id(col_id) {
718 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
719 all_items.push(item);
720 max_len = max_len.max(len);
721 }
722 }
723
724 all_items.push(ListItem::new(""));
726 let (header, header_len) = render_section_header("View as");
727 all_items.push(header);
728 max_len = max_len.max(header_len);
729
730 let (item, len) = render_radio_item(
731 "Table",
732 app.alarms_state.view_as == AlarmViewMode::Table,
733 true,
734 );
735 all_items.push(item);
736 max_len = max_len.max(len);
737
738 let (item, len) = render_radio_item(
739 "Cards",
740 app.alarms_state.view_as == AlarmViewMode::Cards,
741 true,
742 );
743 all_items.push(item);
744 max_len = max_len.max(len);
745
746 all_items.push(ListItem::new(""));
748 let (page_items, page_len) = render_page_size_section(
749 app.alarms_state.table.page_size,
750 &[
751 (PageSize::Ten, "10"),
752 (PageSize::TwentyFive, "25"),
753 (PageSize::Fifty, "50"),
754 (PageSize::OneHundred, "100"),
755 ],
756 );
757 all_items.extend(page_items);
758 max_len = max_len.max(page_len);
759
760 all_items.push(ListItem::new(""));
762 let (header, header_len) = render_section_header("Wrap lines");
763 all_items.push(header);
764 max_len = max_len.max(header_len);
765
766 let (item, len) = render_column_toggle_string("Wrap lines", app.alarms_state.wrap_lines);
767 all_items.push(item);
768 max_len = max_len.max(len);
769
770 (all_items, " Preferences ", max_len)
771 } else if app.view_mode == ViewMode::Events {
772 let mut max_len = 0;
773 let items: Vec<ListItem> = app
774 .cw_log_event_column_ids
775 .iter()
776 .filter_map(|col_id| {
777 EventColumn::from_id(col_id).map(|col| {
778 let is_visible = app.cw_log_event_visible_column_ids.contains(col_id);
779 let (item, len) = render_column_toggle_string(col.name(), is_visible);
780 max_len = max_len.max(len);
781 item
782 })
783 })
784 .collect();
785 (items, " Select visible columns (Space to toggle) ", max_len)
786 } else if app.view_mode == ViewMode::Detail {
787 let mut max_len = 0;
788 let items: Vec<ListItem> = app
789 .cw_log_stream_column_ids
790 .iter()
791 .filter_map(|col_id| {
792 StreamColumn::from_id(col_id).map(|col| {
793 let is_visible = app.cw_log_stream_visible_column_ids.contains(col_id);
794 let (item, len) = render_column_toggle_string(col.name(), is_visible);
795 max_len = max_len.max(len);
796 item
797 })
798 })
799 .collect();
800 (items, " Preferences ", max_len)
801 } else if app.current_service == Service::CloudWatchLogGroups {
802 let mut max_len = 0;
803 let items: Vec<ListItem> = app
804 .cw_log_group_column_ids
805 .iter()
806 .filter_map(|col_id| {
807 LogGroupColumn::from_id(col_id).map(|col| {
808 let is_visible = app.cw_log_group_visible_column_ids.contains(col_id);
809 let (item, len) = render_column_toggle_string(col.name(), is_visible);
810 max_len = max_len.max(len);
811 item
812 })
813 })
814 .collect();
815 (items, " Preferences ", max_len)
816 } else if app.current_service == Service::EcrRepositories {
817 let mut max_len = 0;
818 let items: Vec<ListItem> = if app.ecr_state.current_repository.is_some() {
819 app.ecr_image_column_ids
821 .iter()
822 .filter_map(|col_id| {
823 image::Column::from_id(col_id).map(|col| {
824 let is_visible = app.ecr_image_visible_column_ids.contains(col_id);
825 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
826 max_len = max_len.max(len);
827 item
828 })
829 })
830 .collect()
831 } else {
832 app.ecr_repo_column_ids
834 .iter()
835 .filter_map(|col_id| {
836 repo::Column::from_id(col_id).map(|col| {
837 let is_visible = app.ecr_repo_visible_column_ids.contains(col_id);
838 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
839 max_len = max_len.max(len);
840 item
841 })
842 })
843 .collect()
844 };
845 (items, " Preferences ", max_len)
846 } else if app.current_service == Service::SqsQueues {
847 if app.sqs_state.current_queue.is_some()
848 && app.sqs_state.detail_tab == crate::ui::sqs::QueueDetailTab::LambdaTriggers
849 {
850 let mut all_items: Vec<ListItem> = Vec::new();
852 let mut max_len = 0;
853
854 let (header, header_len) = render_section_header("Columns");
855 all_items.push(header);
856 max_len = max_len.max(header_len);
857
858 for col_id in &app.sqs_state.trigger_column_ids {
859 if let Some(col) = SqsTriggerColumn::from_id(col_id) {
860 let is_visible = app.sqs_state.trigger_visible_column_ids.contains(col_id);
861 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
862 all_items.push(item);
863 max_len = max_len.max(len);
864 }
865 }
866
867 all_items.push(ListItem::new(""));
868 let (page_items, page_len) = render_page_size_section(
869 app.sqs_state.triggers.page_size,
870 &[
871 (PageSize::Ten, "10"),
872 (PageSize::TwentyFive, "25"),
873 (PageSize::Fifty, "50"),
874 (PageSize::OneHundred, "100"),
875 ],
876 );
877 all_items.extend(page_items);
878 max_len = max_len.max(page_len);
879
880 (all_items, " Preferences ", max_len)
881 } else {
882 let mut max_len = 0;
884 let items: Vec<ListItem> = app
885 .sqs_column_ids
886 .iter()
887 .filter_map(|col_id| {
888 SqsColumn::from_id(col_id).map(|col| {
889 let is_visible = app.sqs_visible_column_ids.contains(col_id);
890 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
891 max_len = max_len.max(len);
892 item
893 })
894 })
895 .collect();
896 (items, " Preferences ", max_len)
897 }
898 } else if app.current_service == Service::LambdaFunctions {
899 let mut all_items: Vec<ListItem> = Vec::new();
900 let mut max_len = 0;
901
902 let (header, header_len) = render_section_header("Columns");
903 all_items.push(header);
904 max_len = max_len.max(header_len);
905
906 if app.lambda_state.current_function.is_some()
908 && app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Code
909 {
910 for col in &app.lambda_state.layer_column_ids {
912 let is_visible = app.lambda_state.layer_visible_column_ids.contains(col);
913 let (item, len) = render_column_toggle_string(col, is_visible);
914 all_items.push(item);
915 max_len = max_len.max(len);
916 }
917 } else if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Versions {
918 for col in &app.lambda_state.version_column_ids {
919 let is_visible = app.lambda_state.version_visible_column_ids.contains(col);
920 let (item, len) = render_column_toggle_string(col, is_visible);
921 all_items.push(item);
922 max_len = max_len.max(len);
923 }
924 } else if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Aliases {
925 for col in &app.lambda_state.alias_column_ids {
926 let is_visible = app.lambda_state.alias_visible_column_ids.contains(col);
927 let (item, len) = render_column_toggle_string(col, is_visible);
928 all_items.push(item);
929 max_len = max_len.max(len);
930 }
931 } else {
932 for col_id in &app.lambda_state.function_column_ids {
933 if let Some(col) = crate::lambda::FunctionColumn::from_id(col_id) {
934 let is_visible = app
935 .lambda_state
936 .function_visible_column_ids
937 .contains(col_id);
938 let (item, len) = render_column_toggle_string(col.name(), is_visible);
939 all_items.push(item);
940 max_len = max_len.max(len);
941 }
942 }
943 }
944
945 all_items.push(ListItem::new(""));
946
947 let (page_items, page_len) = render_page_size_section(
948 if app.lambda_state.detail_tab == crate::app::LambdaDetailTab::Versions {
949 app.lambda_state.version_table.page_size
950 } else {
951 app.lambda_state.table.page_size
952 },
953 &[
954 (PageSize::Ten, "10"),
955 (PageSize::TwentyFive, "25"),
956 (PageSize::Fifty, "50"),
957 (PageSize::OneHundred, "100"),
958 ],
959 );
960 all_items.extend(page_items);
961 max_len = max_len.max(page_len);
962
963 (all_items, " Preferences ", max_len)
964 } else if app.current_service == Service::LambdaApplications {
965 let mut all_items: Vec<ListItem> = Vec::new();
966 let mut max_len = 0;
967
968 let (header, header_len) = render_section_header("Columns");
969 all_items.push(header);
970 max_len = max_len.max(header_len);
971
972 if app.lambda_application_state.current_application.is_some() {
974 use crate::ui::lambda::ApplicationDetailTab;
975 if app.lambda_application_state.detail_tab == ApplicationDetailTab::Overview {
976 for col_id in &app.lambda_resource_column_ids {
978 let is_visible = app.lambda_resource_visible_column_ids.contains(col_id);
979 if let Some(col) = ResourceColumn::from_id(col_id) {
980 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
981 all_items.push(item);
982 max_len = max_len.max(len);
983 }
984 }
985
986 all_items.push(ListItem::new(""));
987 let (page_items, page_len) = render_page_size_section(
988 app.lambda_application_state.resources.page_size,
989 &[
990 (PageSize::Ten, "10"),
991 (PageSize::TwentyFive, "25"),
992 (PageSize::Fifty, "50"),
993 ],
994 );
995 all_items.extend(page_items);
996 max_len = max_len.max(page_len);
997 } else {
998 for col_id in &app.lambda_deployment_column_ids {
1000 let is_visible = app.lambda_deployment_visible_column_ids.contains(col_id);
1001 if let Some(col) = DeploymentColumn::from_id(col_id) {
1002 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1003 all_items.push(item);
1004 max_len = max_len.max(len);
1005 }
1006 }
1007
1008 all_items.push(ListItem::new(""));
1009 let (page_items, page_len) = render_page_size_section(
1010 app.lambda_application_state.deployments.page_size,
1011 &[
1012 (PageSize::Ten, "10"),
1013 (PageSize::TwentyFive, "25"),
1014 (PageSize::Fifty, "50"),
1015 ],
1016 );
1017 all_items.extend(page_items);
1018 max_len = max_len.max(page_len);
1019 }
1020 } else {
1021 for col_id in &app.lambda_application_column_ids {
1023 if let Some(col) = crate::lambda::ApplicationColumn::from_id(col_id) {
1024 let is_visible = app.lambda_application_visible_column_ids.contains(col_id);
1025 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1026 all_items.push(item);
1027 max_len = max_len.max(len);
1028 }
1029 }
1030
1031 all_items.push(ListItem::new(""));
1032 let (page_items, page_len) = render_page_size_section(
1033 app.lambda_application_state.table.page_size,
1034 &[
1035 (PageSize::Ten, "10"),
1036 (PageSize::TwentyFive, "25"),
1037 (PageSize::Fifty, "50"),
1038 ],
1039 );
1040 all_items.extend(page_items);
1041 max_len = max_len.max(page_len);
1042 }
1043
1044 (all_items, " Preferences ", max_len)
1045 } else if app.current_service == Service::CloudFormationStacks {
1046 let mut all_items: Vec<ListItem> = Vec::new();
1047 let mut max_len = 0;
1048
1049 let (header, header_len) = render_section_header("Columns");
1050 all_items.push(header);
1051 max_len = max_len.max(header_len);
1052
1053 for col_id in &app.cfn_column_ids {
1054 let is_visible = app.cfn_visible_column_ids.contains(col_id);
1055 if let Some(col) = CfnColumn::from_id(col_id) {
1056 let (item, len) = render_column_toggle_string(&col.name(), is_visible);
1057 all_items.push(item);
1058 max_len = max_len.max(len);
1059 }
1060 }
1061
1062 all_items.push(ListItem::new(""));
1063 let (page_items, page_len) = render_page_size_section(
1064 app.cfn_state.table.page_size,
1065 &[
1066 (PageSize::Ten, "10"),
1067 (PageSize::TwentyFive, "25"),
1068 (PageSize::Fifty, "50"),
1069 (PageSize::OneHundred, "100"),
1070 ],
1071 );
1072 all_items.extend(page_items);
1073 max_len = max_len.max(page_len);
1074
1075 (all_items, " Preferences ", max_len)
1076 } else if app.current_service == Service::IamUsers {
1077 let mut all_items: Vec<ListItem> = Vec::new();
1078 let mut max_len = 0;
1079
1080 if app.iam_state.current_user.is_some()
1082 && app.iam_state.user_tab == crate::ui::iam::UserTab::Permissions
1083 {
1084 let (header, header_len) = render_section_header("Columns");
1085 all_items.push(header);
1086 max_len = max_len.max(header_len);
1087
1088 for col in &app.iam_policy_column_ids {
1089 let is_visible = app.iam_policy_visible_column_ids.contains(col);
1090 let mut spans = vec![];
1091 spans.extend(render_toggle(is_visible));
1092 spans.push(Span::raw(" "));
1093 spans.push(Span::raw(col.clone()));
1094 let text_len = 4 + col.len();
1095 all_items.push(ListItem::new(Line::from(spans)));
1096 max_len = max_len.max(text_len);
1097 }
1098
1099 all_items.push(ListItem::new(""));
1100 let (page_items, page_len) = render_page_size_section(
1101 app.iam_state.policies.page_size,
1102 &[
1103 (PageSize::Ten, "10"),
1104 (PageSize::TwentyFive, "25"),
1105 (PageSize::Fifty, "50"),
1106 ],
1107 );
1108 all_items.extend(page_items);
1109 max_len = max_len.max(page_len);
1110 } else if app.iam_state.current_user.is_none() {
1111 let (header, header_len) = render_section_header("Columns");
1112 all_items.push(header);
1113 max_len = max_len.max(header_len);
1114
1115 for col in &app.iam_user_column_ids {
1116 let is_visible = app.iam_user_visible_column_ids.contains(col);
1117 let (item, len) = render_column_toggle_string(col, is_visible);
1118 all_items.push(item);
1119 max_len = max_len.max(len);
1120 }
1121
1122 all_items.push(ListItem::new(""));
1123 let (page_items, page_len) = render_page_size_section(
1124 app.iam_state.users.page_size,
1125 &[
1126 (PageSize::Ten, "10"),
1127 (PageSize::TwentyFive, "25"),
1128 (PageSize::Fifty, "50"),
1129 ],
1130 );
1131 all_items.extend(page_items);
1132 max_len = max_len.max(page_len);
1133 }
1134
1135 (all_items, " Preferences ", max_len)
1136 } else if app.current_service == Service::IamRoles {
1137 let mut all_items: Vec<ListItem> = Vec::new();
1138 let mut max_len = 0;
1139
1140 let (header, header_len) = render_section_header("Columns");
1141 all_items.push(header);
1142 max_len = max_len.max(header_len);
1143
1144 for col in &app.iam_role_column_ids {
1145 let is_visible = app.iam_role_visible_column_ids.contains(col);
1146 let mut spans = vec![];
1147 spans.extend(render_toggle(is_visible));
1148 spans.push(Span::raw(" "));
1149 spans.push(Span::raw(col.clone()));
1150 let text_len = 4 + col.len();
1151 all_items.push(ListItem::new(Line::from(spans)));
1152 max_len = max_len.max(text_len);
1153 }
1154
1155 all_items.push(ListItem::new(""));
1156 let (page_items, page_len) = render_page_size_section(
1157 app.iam_state.roles.page_size,
1158 &[
1159 (PageSize::Ten, "10"),
1160 (PageSize::TwentyFive, "25"),
1161 (PageSize::Fifty, "50"),
1162 ],
1163 );
1164 all_items.extend(page_items);
1165 max_len = max_len.max(page_len);
1166
1167 (all_items, " Preferences ", max_len)
1168 } else if app.current_service == Service::IamUserGroups {
1169 let mut all_items: Vec<ListItem> = Vec::new();
1170 let mut max_len = 0;
1171
1172 let (header, header_len) = render_section_header("Columns");
1173 all_items.push(header);
1174 max_len = max_len.max(header_len);
1175
1176 for col in &app.iam_group_column_ids {
1177 let is_visible = app.iam_group_visible_column_ids.contains(col);
1178 let mut spans = vec![];
1179 spans.extend(render_toggle(is_visible));
1180 spans.push(Span::raw(" "));
1181 spans.push(Span::raw(col.clone()));
1182 let text_len = 4 + col.len();
1183 all_items.push(ListItem::new(Line::from(spans)));
1184 max_len = max_len.max(text_len);
1185 }
1186
1187 all_items.push(ListItem::new(""));
1188 let (page_items, page_len) = render_page_size_section(
1189 app.iam_state.groups.page_size,
1190 &[
1191 (PageSize::Ten, "10"),
1192 (PageSize::TwentyFive, "25"),
1193 (PageSize::Fifty, "50"),
1194 ],
1195 );
1196 all_items.extend(page_items);
1197 max_len = max_len.max(page_len);
1198
1199 (all_items, " Preferences ", max_len)
1200 } else {
1201 (vec![], " Preferences ", 0)
1203 };
1204
1205 let item_count = items.len();
1207
1208 let width = (max_text_len + 10).clamp(30, 100) as u16; let height = (item_count as u16 + 2).max(8); let max_height = area.height.saturating_sub(4);
1214 let actual_height = height.min(max_height);
1215 let popup_area = centered_rect_absolute(width, actual_height, area);
1216
1217 let needs_scrollbar = height > max_height;
1219
1220 let border_color = Color::Green;
1222
1223 let list = List::new(items)
1224 .block(
1225 Block::default()
1226 .title(title)
1227 .borders(Borders::ALL)
1228 .border_type(BorderType::Rounded)
1229 .border_type(BorderType::Rounded)
1230 .border_style(Style::default().fg(border_color)),
1231 )
1232 .highlight_style(Style::default().bg(Color::DarkGray))
1233 .highlight_symbol("► ");
1234
1235 let mut state = ListState::default();
1236 state.select(Some(app.column_selector_index));
1237
1238 frame.render_widget(Clear, popup_area);
1239 frame.render_stateful_widget(list, popup_area, &mut state);
1240
1241 if needs_scrollbar {
1243 crate::common::render_scrollbar(
1244 frame,
1245 popup_area.inner(Margin {
1246 vertical: 1,
1247 horizontal: 0,
1248 }),
1249 item_count,
1250 app.column_selector_index,
1251 );
1252 }
1253}
1254
1255fn render_error_modal(frame: &mut Frame, app: &App, area: Rect) {
1256 let popup_area = centered_rect(80, 60, area);
1257
1258 frame.render_widget(Clear, popup_area);
1259 frame.render_widget(
1260 Block::default()
1261 .title(" Error ")
1262 .borders(Borders::ALL)
1263 .border_type(BorderType::Rounded)
1264 .border_type(BorderType::Rounded)
1265 .border_style(crate::ui::red_text())
1266 .style(Style::default().bg(Color::Black)),
1267 popup_area,
1268 );
1269
1270 let inner = popup_area.inner(Margin {
1271 vertical: 1,
1272 horizontal: 1,
1273 });
1274
1275 let error_text = app.error_message.as_deref().unwrap_or("Unknown error");
1276
1277 let chunks = vertical(
1278 [
1279 Constraint::Length(2), Constraint::Min(0), Constraint::Length(2), ],
1283 inner,
1284 );
1285
1286 let header = Paragraph::new("AWS Error")
1288 .alignment(Alignment::Center)
1289 .style(crate::ui::red_text().add_modifier(Modifier::BOLD));
1290 frame.render_widget(header, chunks[0]);
1291
1292 let error_lines: Vec<Line> = error_text
1294 .lines()
1295 .skip(app.error_scroll)
1296 .flat_map(|line| {
1297 let width = chunks[1].width.saturating_sub(4) as usize; if line.len() <= width {
1299 vec![Line::from(line)]
1300 } else {
1301 line.chars()
1302 .collect::<Vec<_>>()
1303 .chunks(width)
1304 .map(|chunk| Line::from(chunk.iter().collect::<String>()))
1305 .collect()
1306 }
1307 })
1308 .collect();
1309
1310 let error_paragraph = Paragraph::new(error_lines)
1311 .block(
1312 Block::default()
1313 .borders(Borders::ALL)
1314 .border_type(BorderType::Rounded)
1315 .border_type(BorderType::Rounded)
1316 .border_style(crate::ui::active_border()),
1317 )
1318 .style(Style::default().fg(Color::White));
1319
1320 frame.render_widget(error_paragraph, chunks[1]);
1321
1322 let total_lines: usize = error_text
1324 .lines()
1325 .map(|line| {
1326 let width = chunks[1].width.saturating_sub(4) as usize;
1327 if line.len() <= width {
1328 1
1329 } else {
1330 line.len().div_ceil(width)
1331 }
1332 })
1333 .sum();
1334 let visible_lines = chunks[1].height.saturating_sub(2) as usize;
1335 if total_lines > visible_lines {
1336 crate::common::render_scrollbar(
1337 frame,
1338 chunks[1].inner(Margin {
1339 vertical: 1,
1340 horizontal: 0,
1341 }),
1342 total_lines,
1343 app.error_scroll,
1344 );
1345 }
1346
1347 let help_spans = vec![
1349 first_hint("^r", "retry"),
1350 hint("y", "copy"),
1351 hint("↑↓,^u,^d", "scroll"),
1352 last_hint("q,⎋", "close"),
1353 ]
1354 .into_iter()
1355 .flatten()
1356 .collect::<Vec<_>>();
1357 let help = Paragraph::new(Line::from(help_spans)).alignment(Alignment::Center);
1358
1359 frame.render_widget(help, chunks[2]);
1360}
1361
1362fn render_space_menu(frame: &mut Frame, area: Rect) {
1363 let items = vec![
1364 Line::from(vec![
1365 Span::styled("o", Style::default().fg(Color::Yellow)),
1366 Span::raw(" services"),
1367 ]),
1368 Line::from(vec![
1369 Span::styled("t", Style::default().fg(Color::Yellow)),
1370 Span::raw(" tabs"),
1371 ]),
1372 Line::from(vec![
1373 Span::styled("c", Style::default().fg(Color::Yellow)),
1374 Span::raw(" close"),
1375 ]),
1376 Line::from(vec![
1377 Span::styled("r", Style::default().fg(Color::Yellow)),
1378 Span::raw(" regions"),
1379 ]),
1380 Line::from(vec![
1381 Span::styled("s", Style::default().fg(Color::Yellow)),
1382 Span::raw(" sessions"),
1383 ]),
1384 Line::from(vec![
1385 Span::styled("h", Style::default().fg(Color::Yellow)),
1386 Span::raw(" help"),
1387 ]),
1388 ];
1389
1390 let menu_height = items.len() as u16 + 2; let menu_area = bottom_right_rect(30, menu_height, area);
1392
1393 let paragraph = Paragraph::new(items)
1394 .block(
1395 Block::default()
1396 .title(" Menu ")
1397 .borders(Borders::ALL)
1398 .border_type(BorderType::Rounded)
1399 .border_type(BorderType::Rounded)
1400 .border_type(BorderType::Rounded)
1401 .border_style(Style::default().fg(Color::Cyan)),
1402 )
1403 .style(Style::default().bg(Color::Black));
1404
1405 frame.render_widget(Clear, menu_area);
1406 frame.render_widget(paragraph, menu_area);
1407}
1408
1409fn render_service_picker(frame: &mut Frame, app: &App, area: Rect) {
1410 let popup_area = centered_rect(60, 60, area);
1411
1412 let chunks = Layout::default()
1413 .direction(Direction::Vertical)
1414 .constraints([Constraint::Length(3), Constraint::Min(0)])
1415 .split(popup_area);
1416
1417 let is_active = app.mode == Mode::ServicePicker;
1418 let cursor = get_cursor(is_active);
1419 let active_color = Color::Green;
1420 let inactive_color = Color::Cyan;
1421 let filter = Paragraph::new(Line::from(vec![
1422 Span::raw(&app.service_picker.filter),
1423 Span::styled(cursor, Style::default().fg(active_color)),
1424 ]))
1425 .block(
1426 Block::default()
1427 .title(SEARCH_ICON)
1428 .borders(Borders::ALL)
1429 .border_type(BorderType::Rounded)
1430 .border_type(BorderType::Rounded)
1431 .border_style(Style::default().fg(if is_active {
1432 active_color
1433 } else {
1434 inactive_color
1435 })),
1436 )
1437 .style(Style::default());
1438
1439 let filtered = app.filtered_services();
1440 let items: Vec<ListItem> = filtered.iter().map(|s| ListItem::new(*s)).collect();
1441
1442 let list = List::new(items)
1443 .block(
1444 Block::default()
1445 .title(" AWS Services ")
1446 .borders(Borders::ALL)
1447 .border_type(BorderType::Rounded)
1448 .border_type(BorderType::Rounded)
1449 .border_type(BorderType::Rounded)
1450 .border_style(if is_active {
1451 active_border()
1452 } else {
1453 Style::default().fg(Color::Cyan)
1454 }),
1455 )
1456 .highlight_style(Style::default().bg(Color::DarkGray))
1457 .highlight_symbol("► ");
1458
1459 let mut state = ListState::default();
1460 state.select(Some(app.service_picker.selected));
1461
1462 frame.render_widget(Clear, popup_area);
1463 frame.render_widget(filter, chunks[0]);
1464 frame.render_stateful_widget(list, chunks[1], &mut state);
1465}
1466
1467fn render_tab_picker(frame: &mut Frame, app: &App, area: Rect) {
1468 let popup_area = centered_rect(80, 60, area);
1469
1470 let main_chunks = Layout::default()
1472 .direction(Direction::Vertical)
1473 .constraints([Constraint::Length(3), Constraint::Min(0)])
1474 .split(popup_area);
1475
1476 let filter_text = if app.tab_filter.is_empty() {
1478 "Type to filter tabs...".to_string()
1479 } else {
1480 app.tab_filter.clone()
1481 };
1482 let filter_style = if app.tab_filter.is_empty() {
1483 Style::default().fg(Color::DarkGray)
1484 } else {
1485 Style::default()
1486 };
1487 let filter = Paragraph::new(filter_text).style(filter_style).block(
1488 Block::default()
1489 .title(SEARCH_ICON)
1490 .borders(Borders::ALL)
1491 .border_type(BorderType::Rounded)
1492 .border_type(BorderType::Rounded)
1493 .border_style(Style::default().fg(Color::Yellow)),
1494 );
1495 frame.render_widget(Clear, main_chunks[0]);
1496 frame.render_widget(filter, main_chunks[0]);
1497
1498 let chunks = Layout::default()
1499 .direction(Direction::Horizontal)
1500 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1501 .split(main_chunks[1]);
1502
1503 let filtered_tabs = app.get_filtered_tabs();
1505 let items: Vec<ListItem> = filtered_tabs
1506 .iter()
1507 .map(|(_, tab)| ListItem::new(tab.breadcrumb.clone()))
1508 .collect();
1509
1510 let list = List::new(items)
1511 .block(
1512 Block::default()
1513 .title(format!(
1514 " Tabs ({}/{}) ",
1515 filtered_tabs.len(),
1516 app.tabs.len()
1517 ))
1518 .borders(Borders::ALL)
1519 .border_type(BorderType::Rounded)
1520 .border_type(BorderType::Rounded)
1521 .border_type(BorderType::Rounded)
1522 .border_style(crate::ui::active_border()),
1523 )
1524 .highlight_style(Style::default().bg(Color::DarkGray))
1525 .highlight_symbol("► ");
1526
1527 let mut state = ListState::default();
1528 state.select(Some(app.tab_picker_selected));
1529
1530 frame.render_widget(Clear, chunks[0]);
1531 frame.render_stateful_widget(list, chunks[0], &mut state);
1532
1533 frame.render_widget(Clear, chunks[1]);
1535
1536 let preview_block = Block::default()
1537 .title(" Preview ")
1538 .borders(Borders::ALL)
1539 .border_type(BorderType::Rounded)
1540 .border_type(BorderType::Rounded)
1541 .border_style(Style::default().fg(Color::Cyan));
1542
1543 let preview_inner = preview_block.inner(chunks[1]);
1544 frame.render_widget(preview_block, chunks[1]);
1545
1546 if let Some(&(_, tab)) = filtered_tabs.get(app.tab_picker_selected) {
1547 render_service_preview(frame, app, tab.service, preview_inner);
1550 }
1551}
1552
1553fn render_service_preview(frame: &mut Frame, app: &App, service: Service, area: Rect) {
1554 match service {
1555 Service::CloudWatchLogGroups => {
1556 if app.view_mode == ViewMode::Events {
1557 cw::logs::render_events(frame, app, area);
1558 } else if app.view_mode == ViewMode::Detail {
1559 cw::logs::render_group_detail(frame, app, area);
1560 } else {
1561 cw::logs::render_groups_list(frame, app, area);
1562 }
1563 }
1564 Service::CloudWatchInsights => cw::render_insights(frame, app, area),
1565 Service::CloudWatchAlarms => cw::render_alarms(frame, app, area),
1566 Service::EcrRepositories => ecr::render_repositories(frame, app, area),
1567 Service::LambdaFunctions => lambda::render_functions(frame, app, area),
1568 Service::LambdaApplications => lambda::render_applications(frame, app, area),
1569 Service::S3Buckets => s3::render_buckets(frame, app, area),
1570 Service::SqsQueues => sqs::render_queues(frame, app, area),
1571 Service::CloudFormationStacks => cfn::render_stacks(frame, app, area),
1572 Service::IamUsers => iam::render_users(frame, app, area),
1573 Service::IamRoles => iam::render_roles(frame, app, area),
1574 Service::IamUserGroups => iam::render_user_groups(frame, app, area),
1575 }
1576}
1577
1578fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1579 let popup_layout = Layout::default()
1580 .direction(Direction::Vertical)
1581 .constraints([
1582 Constraint::Percentage((100 - percent_y) / 2),
1583 Constraint::Percentage(percent_y),
1584 Constraint::Percentage((100 - percent_y) / 2),
1585 ])
1586 .split(r);
1587
1588 Layout::default()
1589 .direction(Direction::Horizontal)
1590 .constraints([
1591 Constraint::Percentage((100 - percent_x) / 2),
1592 Constraint::Percentage(percent_x),
1593 Constraint::Percentage((100 - percent_x) / 2),
1594 ])
1595 .split(popup_layout[1])[1]
1596}
1597
1598fn centered_rect_absolute(width: u16, height: u16, r: Rect) -> Rect {
1599 let x = (r.width.saturating_sub(width)) / 2;
1600 let y = (r.height.saturating_sub(height)) / 2;
1601 Rect {
1602 x: r.x + x,
1603 y: r.y + y,
1604 width: width.min(r.width),
1605 height: height.min(r.height),
1606 }
1607}
1608
1609fn bottom_right_rect(width: u16, height: u16, r: Rect) -> Rect {
1610 let x = r.width.saturating_sub(width + 1);
1611 let y = r.height.saturating_sub(height + 1);
1612 Rect {
1613 x: r.x + x,
1614 y: r.y + y,
1615 width: width.min(r.width),
1616 height: height.min(r.height),
1617 }
1618}
1619
1620fn render_help_modal(frame: &mut Frame, area: Rect) {
1621 let help_text = vec![
1622 Line::from(vec![
1623 Span::styled("⎋ ", crate::ui::red_text()),
1624 Span::raw(" Escape"),
1625 ]),
1626 Line::from(vec![
1627 Span::styled("⏎ ", crate::ui::red_text()),
1628 Span::raw(" Enter/Return"),
1629 ]),
1630 Line::from(vec![
1631 Span::styled("⇤⇥ ", crate::ui::red_text()),
1632 Span::raw(" Tab"),
1633 ]),
1634 Line::from(vec![
1635 Span::styled("␣ ", crate::ui::red_text()),
1636 Span::raw(" Space"),
1637 ]),
1638 Line::from(vec![
1639 Span::styled("^r ", crate::ui::red_text()),
1640 Span::raw(" Ctrl+r"),
1641 ]),
1642 Line::from(vec![
1643 Span::styled("^w ", crate::ui::red_text()),
1644 Span::raw(" Ctrl+w"),
1645 ]),
1646 Line::from(vec![
1647 Span::styled("^o ", crate::ui::red_text()),
1648 Span::raw(" Ctrl+o"),
1649 ]),
1650 Line::from(vec![
1651 Span::styled("^p ", crate::ui::red_text()),
1652 Span::raw(" Ctrl+p"),
1653 ]),
1654 Line::from(vec![
1655 Span::styled("^u ", crate::ui::red_text()),
1656 Span::raw(" Ctrl+u (page up)"),
1657 ]),
1658 Line::from(vec![
1659 Span::styled("^d ", crate::ui::red_text()),
1660 Span::raw(" Ctrl+d (page down)"),
1661 ]),
1662 Line::from(vec![
1663 Span::styled("[] ", crate::ui::red_text()),
1664 Span::raw(" [ and ] (switch tabs)"),
1665 ]),
1666 Line::from(vec![
1667 Span::styled("↑↓ ", crate::ui::red_text()),
1668 Span::raw(" Arrow up/down"),
1669 ]),
1670 Line::from(vec![
1671 Span::styled("←→ ", crate::ui::red_text()),
1672 Span::raw(" Arrow left/right"),
1673 ]),
1674 Line::from(""),
1675 Line::from(vec![
1676 Span::styled("Press ", Style::default()),
1677 Span::styled("⎋", crate::ui::red_text()),
1678 Span::styled(" or ", Style::default()),
1679 Span::styled("⏎", crate::ui::red_text()),
1680 Span::styled(" to close", Style::default()),
1681 ]),
1682 ];
1683
1684 let max_width = help_text
1686 .iter()
1687 .map(|line| {
1688 line.spans
1689 .iter()
1690 .map(|span| span.content.len())
1691 .sum::<usize>()
1692 })
1693 .max()
1694 .unwrap_or(80) as u16;
1695
1696 let content_width = max_width + 6; let content_height = help_text.len() as u16 + 2; let popup_width = content_width.min(area.width.saturating_sub(4));
1702 let popup_height = content_height.min(area.height.saturating_sub(4));
1703
1704 let popup_area = Rect {
1705 x: area.x + (area.width.saturating_sub(popup_width)) / 2,
1706 y: area.y + (area.height.saturating_sub(popup_height)) / 2,
1707 width: popup_width,
1708 height: popup_height,
1709 };
1710
1711 let paragraph = Paragraph::new(help_text)
1712 .block(
1713 Block::default()
1714 .title(Span::styled(
1715 " Help ",
1716 Style::default().add_modifier(Modifier::BOLD),
1717 ))
1718 .borders(Borders::ALL)
1719 .border_type(BorderType::Rounded)
1720 .border_type(BorderType::Rounded)
1721 .border_style(crate::ui::active_border())
1722 .padding(Padding::horizontal(1)),
1723 )
1724 .wrap(Wrap { trim: false });
1725
1726 frame.render_widget(Clear, popup_area);
1727 frame.render_widget(paragraph, popup_area);
1728}
1729
1730fn render_region_selector(frame: &mut Frame, app: &App, area: Rect) {
1731 let popup_area = centered_rect(60, 60, area);
1732
1733 let chunks = Layout::default()
1734 .direction(Direction::Vertical)
1735 .constraints([Constraint::Length(3), Constraint::Min(0)])
1736 .split(popup_area);
1737
1738 let cursor = "█";
1740 let filter_text = vec![Span::from(format!("{}{}", app.region_filter, cursor))];
1741 let filter = filter_area(filter_text, true);
1742
1743 let filtered = app.get_filtered_regions();
1745 let items: Vec<ListItem> = filtered
1746 .iter()
1747 .map(|r| {
1748 let latency_str = match r.latency_ms {
1749 Some(ms) => format!("({}ms)", ms),
1750 None => "(>1s)".to_string(),
1751 };
1752 let opt_in = if r.opt_in { "[opt-in] " } else { "" };
1753 let display = format!(
1754 "{} > {} > {} {}{}",
1755 r.group, r.name, r.code, opt_in, latency_str
1756 );
1757 ListItem::new(display)
1758 })
1759 .collect();
1760
1761 let list = List::new(items)
1762 .block(
1763 Block::default()
1764 .title(" Regions ")
1765 .borders(Borders::ALL)
1766 .border_type(BorderType::Rounded)
1767 .border_type(BorderType::Rounded)
1768 .border_style(crate::ui::active_border()),
1769 )
1770 .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White))
1771 .highlight_symbol("▶ ");
1772
1773 frame.render_widget(Clear, popup_area);
1774 frame.render_widget(filter, chunks[0]);
1775 frame.render_stateful_widget(
1776 list,
1777 chunks[1],
1778 &mut ratatui::widgets::ListState::default().with_selected(Some(app.region_picker_selected)),
1779 );
1780}
1781
1782fn render_profile_picker(frame: &mut Frame, app: &App, area: Rect) {
1783 crate::aws::render_profile_picker(frame, app, area, centered_rect);
1784}
1785
1786fn render_session_picker(frame: &mut Frame, app: &App, area: Rect) {
1787 crate::session::render_session_picker(frame, app, area, centered_rect);
1788}
1789
1790fn render_calendar_picker(frame: &mut Frame, app: &App, area: Rect) {
1791 use ratatui::widgets::calendar::{CalendarEventStore, Monthly};
1792
1793 let popup_area = centered_rect(50, 50, area);
1794
1795 let date = app
1796 .calendar_date
1797 .unwrap_or_else(|| time::OffsetDateTime::now_utc().date());
1798
1799 let field_name = match app.calendar_selecting {
1800 crate::app::CalendarField::StartDate => "Start Date",
1801 crate::app::CalendarField::EndDate => "End Date",
1802 };
1803
1804 let events = CalendarEventStore::today(
1805 Style::default()
1806 .add_modifier(Modifier::BOLD)
1807 .bg(Color::Blue),
1808 );
1809
1810 let calendar = Monthly::new(date, events)
1811 .block(
1812 Block::default()
1813 .title(format!(" Select {} ", field_name))
1814 .borders(Borders::ALL)
1815 .border_type(BorderType::Rounded)
1816 .border_type(BorderType::Rounded)
1817 .border_style(crate::ui::active_border()),
1818 )
1819 .show_weekdays_header(Style::new().bold().yellow())
1820 .show_month_header(Style::new().bold().green());
1821
1822 frame.render_widget(Clear, popup_area);
1823 frame.render_widget(calendar, popup_area);
1824}
1825
1826pub fn render_json_highlighted(
1828 frame: &mut Frame,
1829 area: Rect,
1830 json_text: &str,
1831 scroll_offset: usize,
1832 title: &str,
1833) {
1834 let lines: Vec<Line> = json_text
1835 .lines()
1836 .skip(scroll_offset)
1837 .map(|line| {
1838 let mut spans = Vec::new();
1839 let trimmed = line.trim_start();
1840 let indent = line.len() - trimmed.len();
1841
1842 if indent > 0 {
1843 spans.push(Span::raw(" ".repeat(indent)));
1844 }
1845
1846 if trimmed.starts_with('"') && trimmed.contains(':') {
1847 if let Some(colon_pos) = trimmed.find(':') {
1848 spans.push(Span::styled(
1849 &trimmed[..colon_pos],
1850 Style::default().fg(Color::Blue),
1851 ));
1852 spans.push(Span::raw(&trimmed[colon_pos..]));
1853 } else {
1854 spans.push(Span::raw(trimmed));
1855 }
1856 } else if trimmed.starts_with('"') {
1857 spans.push(Span::styled(trimmed, Style::default().fg(Color::Green)));
1858 } else if trimmed.starts_with("true") || trimmed.starts_with("false") {
1859 spans.push(Span::styled(trimmed, Style::default().fg(Color::Yellow)));
1860 } else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
1861 spans.push(Span::styled(trimmed, Style::default().fg(Color::Magenta)));
1862 } else {
1863 spans.push(Span::raw(trimmed));
1864 }
1865
1866 Line::from(spans)
1867 })
1868 .collect();
1869
1870 frame.render_widget(
1871 Paragraph::new(lines).block(
1872 Block::default()
1873 .title(title)
1874 .borders(Borders::ALL)
1875 .border_type(BorderType::Rounded)
1876 .border_type(BorderType::Rounded)
1877 .border_style(crate::ui::active_border()),
1878 ),
1879 area,
1880 );
1881
1882 let total_lines = json_text.lines().count();
1883 if total_lines > 0 {
1884 crate::common::render_scrollbar(
1885 frame,
1886 area.inner(Margin {
1887 vertical: 1,
1888 horizontal: 0,
1889 }),
1890 total_lines,
1891 scroll_offset,
1892 );
1893 }
1894}
1895
1896pub fn render_tags_section<F>(frame: &mut Frame, area: Rect, render_table: F)
1898where
1899 F: FnOnce(&mut Frame, Rect),
1900{
1901 let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1902
1903 frame.render_widget(
1904 Paragraph::new(
1905 "Tags are key-value pairs that you can add to AWS resources to help identify, organize, or search for resources.",
1906 ),
1907 chunks[0],
1908 );
1909
1910 render_table(frame, chunks[1]);
1911}
1912
1913pub fn render_permissions_section<F>(
1915 frame: &mut Frame,
1916 area: Rect,
1917 description: &str,
1918 render_table: F,
1919) where
1920 F: FnOnce(&mut Frame, Rect),
1921{
1922 let chunks = vertical([Constraint::Length(1), Constraint::Min(0)], area);
1923
1924 frame.render_widget(Paragraph::new(description), chunks[0]);
1925
1926 render_table(frame, chunks[1]);
1927}
1928
1929pub fn render_last_accessed_section<F>(
1931 frame: &mut Frame,
1932 area: Rect,
1933 description: &str,
1934 note: &str,
1935 render_table: F,
1936) where
1937 F: FnOnce(&mut Frame, Rect),
1938{
1939 let chunks = vertical(
1940 [
1941 Constraint::Length(1),
1942 Constraint::Length(1),
1943 Constraint::Min(0),
1944 ],
1945 area,
1946 );
1947
1948 frame.render_widget(Paragraph::new(description), chunks[0]);
1949 frame.render_widget(Paragraph::new(note), chunks[1]);
1950
1951 render_table(frame, chunks[2]);
1952}
1953
1954#[cfg(test)]
1955mod tests {
1956 use super::*;
1957 use crate::app::Service;
1958 use crate::app::Tab;
1959 use crate::ecr::image::Image as EcrImage;
1960 use crate::ecr::repo::Repository as EcrRepository;
1961 use crate::keymap::Action;
1962 use crate::lambda;
1963 use crate::ui::table::Column;
1964
1965 fn test_app() -> App {
1966 App::new_without_client("test".to_string(), Some("us-east-1".to_string()))
1967 }
1968
1969 fn test_app_no_region() -> App {
1970 App::new_without_client("test".to_string(), None)
1971 }
1972
1973 #[test]
1974 fn test_expanded_content_wrapping_marks_continuation_lines() {
1975 let max_width = 50;
1977 let col_name = "Message: ";
1978 let value = "This is a very long message that will definitely exceed the maximum width and need to be wrapped";
1979 let full_line = format!("{}{}", col_name, value);
1980
1981 let mut lines = Vec::new();
1982
1983 if full_line.len() <= max_width {
1984 lines.push((full_line, true));
1985 } else {
1986 let first_chunk_len = max_width.min(full_line.len());
1987 lines.push((full_line[..first_chunk_len].to_string(), true));
1988
1989 let mut remaining = &full_line[first_chunk_len..];
1990 while !remaining.is_empty() {
1991 let take = max_width.min(remaining.len());
1992 lines.push((remaining[..take].to_string(), false));
1993 remaining = &remaining[take..];
1994 }
1995 }
1996
1997 assert!(lines[0].1);
1999 assert!(!lines[1].1);
2001 assert!(lines.len() > 1);
2002 }
2003
2004 #[test]
2005 fn test_expanded_content_short_line_not_wrapped() {
2006 let max_width = 100;
2007 let col_name = "Timestamp: ";
2008 let value = "2025-03-13 19:49:30 (UTC)";
2009 let full_line = format!("{}{}", col_name, value);
2010
2011 let mut lines = Vec::new();
2012
2013 if full_line.len() <= max_width {
2014 lines.push((full_line.clone(), true));
2015 } else {
2016 let first_chunk_len = max_width.min(full_line.len());
2017 lines.push((full_line[..first_chunk_len].to_string(), true));
2018
2019 let mut remaining = &full_line[first_chunk_len..];
2020 while !remaining.is_empty() {
2021 let take = max_width.min(remaining.len());
2022 lines.push((remaining[..take].to_string(), false));
2023 remaining = &remaining[take..];
2024 }
2025 }
2026
2027 assert_eq!(lines.len(), 1);
2029 assert!(lines[0].1);
2030 assert_eq!(lines[0].0, full_line);
2031 }
2032
2033 #[test]
2034 fn test_tabs_display_with_separator() {
2035 let tabs = [
2037 Tab {
2038 service: Service::CloudWatchLogGroups,
2039 title: "CloudWatch > Log Groups".to_string(),
2040 breadcrumb: "CloudWatch > Log Groups".to_string(),
2041 },
2042 Tab {
2043 service: Service::CloudWatchInsights,
2044 title: "CloudWatch > Logs Insights".to_string(),
2045 breadcrumb: "CloudWatch > Logs Insights".to_string(),
2046 },
2047 ];
2048
2049 let mut spans = Vec::new();
2050 for (i, tab) in tabs.iter().enumerate() {
2051 if i > 0 {
2052 spans.push(Span::raw(" ⋮ "));
2053 }
2054 spans.push(Span::raw(tab.title.clone()));
2055 }
2056
2057 assert_eq!(spans.len(), 3);
2059 assert_eq!(spans[1].content, " ⋮ ");
2060 }
2061
2062 #[test]
2063 fn test_current_tab_highlighted() {
2064 let tabs = [
2065 crate::app::Tab {
2066 service: Service::CloudWatchLogGroups,
2067 title: "CloudWatch > Log Groups".to_string(),
2068 breadcrumb: "CloudWatch > Log Groups".to_string(),
2069 },
2070 crate::app::Tab {
2071 service: Service::CloudWatchInsights,
2072 title: "CloudWatch > Logs Insights".to_string(),
2073 breadcrumb: "CloudWatch > Logs Insights".to_string(),
2074 },
2075 ];
2076 let current_tab = 1;
2077
2078 let mut spans = Vec::new();
2079 for (i, tab) in tabs.iter().enumerate() {
2080 if i > 0 {
2081 spans.push(Span::raw(" ⋮ "));
2082 }
2083 if i == current_tab {
2084 spans.push(Span::styled(
2085 tab.title.clone(),
2086 Style::default()
2087 .fg(Color::Yellow)
2088 .add_modifier(Modifier::BOLD),
2089 ));
2090 } else {
2091 spans.push(Span::raw(tab.title.clone()));
2092 }
2093 }
2094
2095 assert_eq!(spans[2].style.fg, Some(Color::Yellow));
2097 assert!(spans[2].style.add_modifier.contains(Modifier::BOLD));
2098 assert_eq!(spans[0].style.fg, None);
2100 }
2101
2102 #[test]
2103 fn test_lambda_application_update_complete_shows_green_checkmark() {
2104 let app = crate::lambda::Application {
2105 name: "test-stack".to_string(),
2106 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2107 .to_string(),
2108 description: "Test stack".to_string(),
2109 status: "UPDATE_COMPLETE".to_string(),
2110 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2111 };
2112
2113 let col = crate::lambda::ApplicationColumn::Status;
2114 let (text, style) = col.render(&app);
2115 assert_eq!(text, "✅ UPDATE_COMPLETE");
2116 assert_eq!(style.fg, Some(Color::Green));
2117 }
2118
2119 #[test]
2120 fn test_lambda_application_create_complete_shows_green_checkmark() {
2121 let app = crate::lambda::Application {
2122 name: "test-stack".to_string(),
2123 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2124 .to_string(),
2125 description: "Test stack".to_string(),
2126 status: "CREATE_COMPLETE".to_string(),
2127 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2128 };
2129
2130 let col = crate::lambda::ApplicationColumn::Status;
2131 let (text, style) = col.render(&app);
2132 assert_eq!(text, "✅ CREATE_COMPLETE");
2133 assert_eq!(style.fg, Some(Color::Green));
2134 }
2135
2136 #[test]
2137 fn test_lambda_application_other_status_shows_default() {
2138 let app = crate::lambda::Application {
2139 name: "test-stack".to_string(),
2140 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2141 .to_string(),
2142 description: "Test stack".to_string(),
2143 status: "UPDATE_IN_PROGRESS".to_string(),
2144 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2145 };
2146
2147 let col = crate::lambda::ApplicationColumn::Status;
2148 let (text, style) = col.render(&app);
2149 assert_eq!(text, "ℹ️ UPDATE_IN_PROGRESS");
2150 assert_eq!(style.fg, Some(ratatui::style::Color::LightBlue));
2151 }
2152
2153 #[test]
2154 fn test_lambda_application_status_complete() {
2155 let app = crate::lambda::Application {
2156 name: "test-stack".to_string(),
2157 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2158 .to_string(),
2159 description: "Test stack".to_string(),
2160 status: "UPDATE_COMPLETE".to_string(),
2161 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2162 };
2163
2164 let col = crate::lambda::ApplicationColumn::Status;
2165 let (text, style) = col.render(&app);
2166 assert_eq!(text, "✅ UPDATE_COMPLETE");
2167 assert_eq!(style.fg, Some(ratatui::style::Color::Green));
2168 }
2169
2170 #[test]
2171 fn test_lambda_application_status_failed() {
2172 let app = crate::lambda::Application {
2173 name: "test-stack".to_string(),
2174 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2175 .to_string(),
2176 description: "Test stack".to_string(),
2177 status: "UPDATE_FAILED".to_string(),
2178 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2179 };
2180
2181 let col = crate::lambda::ApplicationColumn::Status;
2182 let (text, style) = col.render(&app);
2183 assert_eq!(text, "❌ UPDATE_FAILED");
2184 assert_eq!(style.fg, Some(ratatui::style::Color::Red));
2185 }
2186
2187 #[test]
2188 fn test_lambda_application_status_rollback() {
2189 let app = crate::lambda::Application {
2190 name: "test-stack".to_string(),
2191 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack/abc123"
2192 .to_string(),
2193 description: "Test stack".to_string(),
2194 status: "UPDATE_ROLLBACK_IN_PROGRESS".to_string(),
2195 last_modified: "2025-10-31 12:00:00 (UTC)".to_string(),
2196 };
2197
2198 let col = crate::lambda::ApplicationColumn::Status;
2199 let (text, style) = col.render(&app);
2200 assert_eq!(text, "❌ UPDATE_ROLLBACK_IN_PROGRESS");
2201 assert_eq!(style.fg, Some(ratatui::style::Color::Red));
2202 }
2203
2204 #[test]
2205 fn test_tab_picker_shows_breadcrumb_and_preview() {
2206 let tabs = [
2207 crate::app::Tab {
2208 service: crate::app::Service::CloudWatchLogGroups,
2209 title: "CloudWatch > Log Groups".to_string(),
2210 breadcrumb: "CloudWatch > Log Groups".to_string(),
2211 },
2212 crate::app::Tab {
2213 service: crate::app::Service::CloudWatchAlarms,
2214 title: "CloudWatch > Alarms".to_string(),
2215 breadcrumb: "CloudWatch > Alarms".to_string(),
2216 },
2217 ];
2218
2219 let selected_idx = 1;
2221 let selected_tab = &tabs[selected_idx];
2222 assert_eq!(selected_tab.breadcrumb, "CloudWatch > Alarms");
2223 assert_eq!(selected_tab.title, "CloudWatch > Alarms");
2224
2225 assert!(selected_tab.breadcrumb.contains("CloudWatch"));
2227 assert!(selected_tab.breadcrumb.contains("Alarms"));
2228 }
2229
2230 #[test]
2231 fn test_tab_picker_has_active_border() {
2232 let border_style = Style::default().fg(Color::Green);
2234 let border_type = BorderType::Plain;
2235
2236 assert_eq!(border_style.fg, Some(Color::Green));
2238 assert_eq!(border_type, BorderType::Plain);
2240 }
2241
2242 #[test]
2243 fn test_tab_picker_title_is_tabs() {
2244 let title = " Tabs ";
2246 assert_eq!(title.trim(), "Tabs");
2247 assert!(!title.contains("Open"));
2248 }
2249
2250 #[test]
2251 fn test_s3_bucket_tabs_no_count_in_tabs() {
2252 let general_purpose_tab = "General purpose buckets (All AWS Regions)";
2254 let directory_tab = "Directory buckets";
2255
2256 assert!(!general_purpose_tab.contains("(0)"));
2258 assert!(!general_purpose_tab.contains("(1)"));
2259 assert!(!directory_tab.contains("(0)"));
2260 assert!(!directory_tab.contains("(1)"));
2261
2262 let table_title = " General purpose buckets (42) ";
2264 assert!(table_title.contains("(42)"));
2265 }
2266
2267 #[test]
2268 fn test_s3_bucket_column_preferences_shows_bucket_columns() {
2269 use crate::app::S3BucketColumn;
2270
2271 let app = test_app();
2272
2273 assert_eq!(app.s3_bucket_column_ids.len(), 3);
2275 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2276
2277 assert_eq!(S3BucketColumn::Name.name(), "Name");
2279 assert_eq!(S3BucketColumn::Region.name(), "Region");
2280 assert_eq!(S3BucketColumn::CreationDate.name(), "Creation date");
2281 }
2282
2283 #[test]
2284 fn test_s3_bucket_columns_not_cloudwatch_columns() {
2285 let app = test_app();
2286
2287 let bucket_col_names: Vec<String> = app
2289 .s3_bucket_column_ids
2290 .iter()
2291 .filter_map(|id| crate::s3::BucketColumn::from_id(id).map(|c| c.name()))
2292 .collect();
2293 let log_col_names: Vec<String> = app
2294 .cw_log_group_column_ids
2295 .iter()
2296 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
2297 .collect();
2298
2299 assert_ne!(bucket_col_names, log_col_names);
2301
2302 assert!(!bucket_col_names.contains(&"Log group".to_string()));
2304 assert!(!bucket_col_names.contains(&"Stored bytes".to_string()));
2305
2306 assert!(bucket_col_names.contains(&"Creation date".to_string()));
2308
2309 assert!(!bucket_col_names.contains(&"AWS Region".to_string()));
2311 }
2312
2313 #[test]
2314 fn test_s3_bucket_column_toggle() {
2315 use crate::app::Service;
2316
2317 let mut app = test_app();
2318 app.current_service = Service::S3Buckets;
2319
2320 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2322
2323 let col = app.s3_bucket_column_ids[1];
2325 if let Some(pos) = app
2326 .s3_bucket_visible_column_ids
2327 .iter()
2328 .position(|c| *c == col)
2329 {
2330 app.s3_bucket_visible_column_ids.remove(pos);
2331 }
2332
2333 assert_eq!(app.s3_bucket_visible_column_ids.len(), 2);
2334 assert!(!app
2335 .s3_bucket_visible_column_ids
2336 .contains(&"column.s3.bucket.region"));
2337
2338 app.s3_bucket_visible_column_ids.push(col);
2340 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2341 assert!(app
2342 .s3_bucket_visible_column_ids
2343 .contains(&"column.s3.bucket.region"));
2344 }
2345
2346 #[test]
2347 fn test_s3_preferences_dialog_title() {
2348 let title = " Preferences ";
2350 assert_eq!(title.trim(), "Preferences");
2351 assert!(!title.contains("Space"));
2352 assert!(!title.contains("toggle"));
2353 }
2354
2355 #[test]
2356 fn test_column_selector_mode_has_hotkey_hints() {
2357 let help = " ↑↓: scroll | ␣: toggle | esc: close ";
2359
2360 assert!(help.contains("␣: toggle"));
2362 assert!(help.contains("↑↓: scroll"));
2363 assert!(help.contains("esc: close"));
2364
2365 assert!(!help.contains("⏎"));
2367 assert!(!help.contains("^w"));
2368 }
2369
2370 #[test]
2371 fn test_date_range_title_no_hints() {
2372 let title = " Date range ";
2374
2375 assert!(!title.contains("Tab to switch"));
2377 assert!(!title.contains("Space to change"));
2378 assert!(!title.contains("("));
2379 assert!(!title.contains(")"));
2380 }
2381
2382 #[test]
2383 fn test_event_filter_mode_has_hints_in_status_bar() {
2384 let help = " tab: switch | ␣: change unit | enter: apply | esc: cancel | ctrl+w: close ";
2386
2387 assert!(help.contains("tab: switch"));
2389 assert!(help.contains("␣: change unit"));
2390 assert!(help.contains("enter: apply"));
2391 assert!(help.contains("esc: cancel"));
2392 }
2393
2394 #[test]
2395 fn test_s3_preferences_shows_all_columns() {
2396 let app = test_app();
2397
2398 assert_eq!(app.s3_bucket_column_ids.len(), 3);
2400
2401 assert_eq!(app.s3_bucket_visible_column_ids.len(), 3);
2403
2404 let names: Vec<String> = app
2406 .s3_bucket_column_ids
2407 .iter()
2408 .filter_map(|id| crate::s3::BucketColumn::from_id(id).map(|c| c.name()))
2409 .collect();
2410 assert_eq!(names, vec!["Name", "Region", "Creation date"]);
2411 }
2412
2413 #[test]
2414 fn test_s3_preferences_has_active_border() {
2415 use ratatui::style::Color;
2416
2417 let border_color = Color::Green;
2419 assert_eq!(border_color, Color::Green);
2420
2421 assert_ne!(border_color, Color::Cyan);
2423 }
2424
2425 #[test]
2426 fn test_s3_table_loses_focus_when_preferences_shown() {
2427 use crate::app::Service;
2428 use crate::keymap::Mode;
2429 use ratatui::style::Color;
2430
2431 let mut app = test_app();
2432 app.current_service = Service::S3Buckets;
2433
2434 app.mode = Mode::Normal;
2436 let is_active = app.mode != Mode::ColumnSelector;
2437 let border_color = if is_active {
2438 Color::Green
2439 } else {
2440 Color::White
2441 };
2442 assert_eq!(border_color, Color::Green);
2443
2444 app.mode = Mode::ColumnSelector;
2446 let is_active = app.mode != Mode::ColumnSelector;
2447 let border_color = if is_active {
2448 Color::Green
2449 } else {
2450 Color::White
2451 };
2452 assert_eq!(border_color, Color::White);
2453 }
2454
2455 #[test]
2456 fn test_s3_object_tabs_cleared_before_render() {
2457 }
2460
2461 #[test]
2462 fn test_s3_properties_tab_shows_bucket_info() {
2463 use crate::app::{S3ObjectTab, Service};
2464
2465 let mut app = test_app();
2466 app.current_service = Service::S3Buckets;
2467 app.s3_state.current_bucket = Some("test-bucket".to_string());
2468 app.s3_state.object_tab = S3ObjectTab::Properties;
2469
2470 assert_eq!(app.s3_state.object_tab, S3ObjectTab::Properties);
2472
2473 assert_eq!(app.s3_state.properties_scroll, 0);
2475 }
2476
2477 #[test]
2478 fn test_s3_properties_scrolling() {
2479 use crate::app::{S3ObjectTab, Service};
2480
2481 let mut app = test_app();
2482 app.current_service = Service::S3Buckets;
2483 app.s3_state.current_bucket = Some("test-bucket".to_string());
2484 app.s3_state.object_tab = S3ObjectTab::Properties;
2485
2486 assert_eq!(app.s3_state.properties_scroll, 0);
2488
2489 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
2491 assert_eq!(app.s3_state.properties_scroll, 1);
2492
2493 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_add(1);
2494 assert_eq!(app.s3_state.properties_scroll, 2);
2495
2496 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2498 assert_eq!(app.s3_state.properties_scroll, 1);
2499
2500 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2501 assert_eq!(app.s3_state.properties_scroll, 0);
2502
2503 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2505 assert_eq!(app.s3_state.properties_scroll, 0);
2506 }
2507
2508 #[test]
2509 fn test_s3_parent_prefix_cleared_before_render() {
2510 }
2513
2514 #[test]
2515 fn test_s3_empty_region_defaults_to_us_east_1() {
2516 let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
2517
2518 let empty_region = "";
2520 let bucket_region = if empty_region.is_empty() {
2521 "us-east-1"
2522 } else {
2523 empty_region
2524 };
2525 assert_eq!(bucket_region, "us-east-1");
2526
2527 let set_region = "us-west-2";
2529 let bucket_region = if set_region.is_empty() {
2530 "us-east-1"
2531 } else {
2532 set_region
2533 };
2534 assert_eq!(bucket_region, "us-west-2");
2535 }
2536
2537 #[test]
2538 fn test_s3_properties_has_multiple_blocks() {
2539 let block_count = 12;
2541 assert_eq!(block_count, 12);
2542
2543 }
2547
2548 #[test]
2549 fn test_s3_properties_tables_use_common_component() {
2550 let tags_columns = ["Key", "Value"];
2553 assert_eq!(tags_columns.len(), 2);
2554
2555 let tiering_columns = [
2557 "Name",
2558 "Status",
2559 "Scope",
2560 "Days to Archive",
2561 "Days to Deep Archive",
2562 ];
2563 assert_eq!(tiering_columns.len(), 5);
2564
2565 let events_columns = [
2567 "Name",
2568 "Event types",
2569 "Filters",
2570 "Destination type",
2571 "Destination",
2572 ];
2573 assert_eq!(events_columns.len(), 5);
2574 }
2575
2576 #[test]
2577 fn test_s3_properties_field_format() {
2578 use ratatui::style::{Modifier, Style};
2580 use ratatui::text::{Line, Span};
2581
2582 let label = Line::from(vec![Span::styled(
2583 "AWS Region",
2584 Style::default().add_modifier(Modifier::BOLD),
2585 )]);
2586 let value = Line::from("us-east-1");
2587
2588 assert!(label.spans[0].style.add_modifier.contains(Modifier::BOLD));
2590
2591 assert!(!value.spans[0].style.add_modifier.contains(Modifier::BOLD));
2593 }
2594
2595 #[test]
2596 fn test_s3_properties_has_scrollbar() {
2597 let total_height = 7 + 5 + 6 + 5 + 4 + 4 + 5 + 4 + 4 + 4 + 4 + 4;
2599 assert_eq!(total_height, 56);
2600
2601 let area_height = 40;
2603 assert!(total_height > area_height);
2604 }
2605
2606 #[test]
2607 fn test_s3_bucket_region_fetched_on_open() {
2608 let empty_region = "";
2613 assert!(empty_region.is_empty());
2614
2615 let fetched_region = "us-west-2";
2617 assert!(!fetched_region.is_empty());
2618 }
2619
2620 #[test]
2621 fn test_s3_filter_space_used_when_hidden() {
2622 let objects_chunks = 4;
2627 let other_chunks = 3;
2628
2629 assert_eq!(objects_chunks, 4);
2630 assert_eq!(other_chunks, 3);
2631 assert!(other_chunks < objects_chunks);
2632 }
2633
2634 #[test]
2635 fn test_s3_properties_scrollable() {
2636 let mut app = test_app();
2637
2638 assert_eq!(app.s3_state.properties_scroll, 0);
2640
2641 app.s3_state.properties_scroll += 1;
2643 assert_eq!(app.s3_state.properties_scroll, 1);
2644
2645 app.s3_state.properties_scroll = app.s3_state.properties_scroll.saturating_sub(1);
2647 assert_eq!(app.s3_state.properties_scroll, 0);
2648 }
2649
2650 #[test]
2651 fn test_s3_properties_scrollbar_conditional() {
2652 let content_height = 40;
2654 let small_viewport = 20;
2655 let large_viewport = 50;
2656
2657 assert!(content_height > small_viewport);
2659
2660 assert!(content_height < large_viewport);
2662 }
2663
2664 #[test]
2665 fn test_s3_tabs_visible_with_styling() {
2666 use ratatui::style::{Color, Modifier, Style};
2667 use ratatui::text::Span;
2668
2669 let active_style = Style::default()
2671 .fg(Color::Yellow)
2672 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
2673 let active_tab = Span::styled("Objects", active_style);
2674 assert_eq!(active_tab.style.fg, Some(Color::Yellow));
2675 assert!(active_tab.style.add_modifier.contains(Modifier::BOLD));
2676 assert!(active_tab.style.add_modifier.contains(Modifier::UNDERLINED));
2677
2678 let inactive_style = Style::default().fg(Color::Gray);
2680 let inactive_tab = Span::styled("Properties", inactive_style);
2681 assert_eq!(inactive_tab.style.fg, Some(Color::Gray));
2682 }
2683
2684 #[test]
2685 fn test_s3_properties_field_labels_bold() {
2686 use ratatui::style::{Modifier, Style};
2687 use ratatui::text::{Line, Span};
2688
2689 let label = Span::styled(
2691 "AWS Region: ",
2692 Style::default().add_modifier(Modifier::BOLD),
2693 );
2694 let value = Span::raw("us-east-1");
2695 let line = Line::from(vec![label.clone(), value.clone()]);
2696
2697 assert!(label.style.add_modifier.contains(Modifier::BOLD));
2699
2700 assert!(!value.style.add_modifier.contains(Modifier::BOLD));
2702
2703 assert_eq!(line.spans.len(), 2);
2705 }
2706
2707 #[test]
2708 fn test_session_picker_dialog_opaque() {
2709 }
2712
2713 #[test]
2714 fn test_status_bar_hotkey_format() {
2715 let separator = " ⋮ ";
2719 assert_eq!(separator, " ⋮ ");
2720
2721 let ctrl_key = "^r";
2723 assert!(ctrl_key.starts_with("^"));
2724 assert!(!ctrl_key.contains("ctrl+"));
2725 assert!(!ctrl_key.contains("ctrl-"));
2726
2727 let shift_key = "^R";
2729 assert!(shift_key.contains("^R"));
2730 assert!(!shift_key.contains("shift+"));
2731 assert!(!shift_key.contains("shift-"));
2732
2733 let old_separator = " | ";
2735 assert_ne!(separator, old_separator);
2736 }
2737
2738 #[test]
2739 fn test_space_key_uses_unicode_symbol() {
2740 let space_symbol = "␣";
2742 assert_eq!(space_symbol, "␣");
2743 assert_eq!(space_symbol.len(), 3); assert_ne!(space_symbol, "space");
2747 assert_ne!(space_symbol, "SPC");
2748 }
2749
2750 #[test]
2751 fn test_region_hotkey_uses_space_menu() {
2752 let region_hotkey = "␣→r";
2754 assert_eq!(region_hotkey, "␣→r");
2755
2756 assert_ne!(region_hotkey, "^R");
2758 assert_ne!(region_hotkey, "ctrl+shift+r");
2759 }
2760
2761 #[test]
2762 fn test_no_incorrect_hotkey_patterns_in_ui() {
2763 let source = include_str!("mod.rs");
2765
2766 let ui_code = if let Some(pos) = source.find("#[cfg(test)]") {
2768 &source[..pos]
2769 } else {
2770 source
2771 };
2772
2773 let space_text_pattern = r#"Span::styled("space""#;
2775 assert!(
2776 !ui_code.contains(space_text_pattern),
2777 "Found 'space' text in hotkey - should use ␣ symbol instead"
2778 );
2779
2780 let lines_with_ctrl_shift_r: Vec<_> = ui_code
2782 .lines()
2783 .enumerate()
2784 .filter(|(_, line)| {
2785 line.contains(r#"Span::styled("^R""#) && line.contains("Color::Red")
2786 })
2787 .collect();
2788
2789 assert!(
2790 lines_with_ctrl_shift_r.is_empty(),
2791 "Found ^R in hotkeys (should use ␣→r for region): {:?}",
2792 lines_with_ctrl_shift_r
2793 );
2794 }
2795
2796 #[test]
2797 fn test_region_only_in_space_menu_not_status_bar() {
2798 let source = include_str!("mod.rs");
2800
2801 let space_menu_start = source
2803 .find("fn render_space_menu")
2804 .expect("render_space_menu function not found");
2805 let space_menu_end = space_menu_start
2806 + source[space_menu_start..]
2807 .find("fn render_service_picker")
2808 .expect("render_service_picker not found");
2809 let space_menu_code = &source[space_menu_start..space_menu_end];
2810
2811 assert!(
2813 space_menu_code.contains(r#"Span::raw(" regions")"#),
2814 "Region must be in Space menu"
2815 );
2816
2817 let status_bar_start = source
2819 .find("fn render_bottom_bar")
2820 .expect("render_bottom_bar function not found");
2821 let status_bar_end = status_bar_start
2822 + source[status_bar_start..]
2823 .find("\nfn render_")
2824 .expect("Next function not found");
2825 let status_bar_code = &source[status_bar_start..status_bar_end];
2826
2827 assert!(
2829 !status_bar_code.contains(" region ⋮ "),
2830 "Region hotkey must NOT be in status bar - it's only in Space menu!"
2831 );
2832 assert!(
2833 !status_bar_code.contains("␣→r"),
2834 "Region hotkey (␣→r) must NOT be in status bar - it's only in Space menu!"
2835 );
2836 assert!(
2837 !status_bar_code.contains("^R"),
2838 "Region hotkey (^R) must NOT be in status bar - it's only in Space menu!"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_s3_bucket_preview_permanent_redirect_handled() {
2844 let error_msg = "PermanentRedirect";
2847 assert!(error_msg.contains("PermanentRedirect"));
2848
2849 let mut preview_map: std::collections::HashMap<String, Vec<crate::app::S3Object>> =
2851 std::collections::HashMap::new();
2852 preview_map.insert("bucket".to_string(), vec![]);
2853 assert!(preview_map.contains_key("bucket"));
2854 }
2855
2856 #[test]
2857 fn test_s3_objects_hint_is_open() {
2858 let hint = "open";
2860 assert_eq!(hint, "open");
2861 assert_ne!(hint, "drill down");
2862 assert_ne!(hint, "open folder");
2863 }
2864
2865 #[test]
2866 fn test_s3_service_tabs_use_cyan() {
2867 let active_color = Color::Cyan;
2869 assert_eq!(active_color, Color::Cyan);
2870 assert_ne!(active_color, Color::Yellow);
2871 }
2872
2873 #[test]
2874 fn test_s3_column_names_use_orange() {
2875 let column_color = Color::LightRed;
2877 assert_eq!(column_color, Color::LightRed);
2878 }
2879
2880 #[test]
2881 fn test_s3_bucket_errors_shown_in_expanded_rows() {
2882 let mut errors: std::collections::HashMap<String, String> =
2884 std::collections::HashMap::new();
2885 errors.insert("bucket".to_string(), "Error message".to_string());
2886 assert!(errors.contains_key("bucket"));
2887 assert_eq!(errors.get("bucket").unwrap(), "Error message");
2888 }
2889
2890 #[test]
2891 fn test_cloudwatch_alarms_page_input() {
2892 let mut app = test_app();
2894 app.current_service = Service::CloudWatchAlarms;
2895 app.page_input = "2".to_string();
2896
2897 assert_eq!(app.page_input, "2");
2899 }
2900
2901 #[test]
2902 fn test_tabs_row_shows_profile_info() {
2903 let profile = "default";
2905 let account = "123456789012";
2906 let region = "us-west-2";
2907 let identity = "role:/MyRole";
2908
2909 let info = format!(
2910 "Profile: {} ⋮ Account: {} ⋮ Region: {} ⋮ Identity: {}",
2911 profile, account, region, identity
2912 );
2913 assert!(info.contains("Profile:"));
2914 assert!(info.contains("Account:"));
2915 assert!(info.contains("Region:"));
2916 assert!(info.contains("Identity:"));
2917 assert!(info.contains("⋮"));
2918 }
2919
2920 #[test]
2921 fn test_tabs_row_profile_labels_are_bold() {
2922 let label_style = Style::default()
2924 .fg(Color::White)
2925 .add_modifier(Modifier::BOLD);
2926 assert!(label_style.add_modifier.contains(Modifier::BOLD));
2927 }
2928
2929 #[test]
2930 fn test_profile_info_not_duplicated() {
2931 let breadcrumbs = "CloudWatch > Alarms";
2934 assert!(!breadcrumbs.contains("Profile:"));
2935 assert!(!breadcrumbs.contains("Account:"));
2936 }
2937
2938 #[test]
2939 fn test_s3_column_headers_are_cyan() {
2940 let header_style = Style::default()
2942 .fg(Color::Cyan)
2943 .add_modifier(Modifier::BOLD);
2944 assert_eq!(header_style.fg, Some(Color::Cyan));
2945 assert!(header_style.add_modifier.contains(Modifier::BOLD));
2946 }
2947
2948 #[test]
2949 fn test_s3_nested_objects_can_be_expanded() {
2950 let mut app = test_app();
2953 app.current_service = Service::S3Buckets;
2954 app.s3_state.current_bucket = Some("bucket".to_string());
2955
2956 app.s3_state.objects.push(crate::app::S3Object {
2958 key: "folder1/".to_string(),
2959 size: 0,
2960 last_modified: String::new(),
2961 is_prefix: true,
2962 storage_class: String::new(),
2963 });
2964
2965 app.s3_state
2967 .expanded_prefixes
2968 .insert("folder1/".to_string());
2969
2970 let nested = vec![crate::app::S3Object {
2972 key: "folder1/subfolder/".to_string(),
2973 size: 0,
2974 last_modified: String::new(),
2975 is_prefix: true,
2976 storage_class: String::new(),
2977 }];
2978 app.s3_state
2979 .prefix_preview
2980 .insert("folder1/".to_string(), nested);
2981
2982 app.s3_state.selected_object = 1;
2984
2985 assert!(app.s3_state.current_bucket.is_some());
2987 }
2988
2989 #[test]
2990 fn test_s3_nested_folder_shows_expand_indicator() {
2991 use crate::app::{S3Object, Service};
2992
2993 let mut app = test_app();
2994 app.current_service = Service::S3Buckets;
2995 app.s3_state.current_bucket = Some("test-bucket".to_string());
2996
2997 app.s3_state.objects = vec![S3Object {
2999 key: "parent/".to_string(),
3000 size: 0,
3001 last_modified: "2024-01-01T00:00:00Z".to_string(),
3002 is_prefix: true,
3003 storage_class: String::new(),
3004 }];
3005
3006 app.s3_state.expanded_prefixes.insert("parent/".to_string());
3008 app.s3_state.prefix_preview.insert(
3009 "parent/".to_string(),
3010 vec![S3Object {
3011 key: "parent/child/".to_string(),
3012 size: 0,
3013 last_modified: "2024-01-01T00:00:00Z".to_string(),
3014 is_prefix: true,
3015 storage_class: String::new(),
3016 }],
3017 );
3018
3019 let child = &app.s3_state.prefix_preview.get("parent/").unwrap()[0];
3021 let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
3022 let indicator = if is_expanded { "▼ " } else { "▶ " };
3023 assert_eq!(indicator, "▶ ");
3024
3025 app.s3_state
3027 .expanded_prefixes
3028 .insert("parent/child/".to_string());
3029 let is_expanded = app.s3_state.expanded_prefixes.contains(&child.key);
3030 let indicator = if is_expanded { "▼ " } else { "▶ " };
3031 assert_eq!(indicator, "▼ ");
3032 }
3033
3034 #[test]
3035 fn test_tabs_row_always_visible() {
3036 let app = test_app();
3039 assert!(!app.service_selected); }
3042
3043 #[test]
3044 fn test_no_duplicate_breadcrumbs_at_root() {
3045 let mut app = test_app();
3047 app.current_service = Service::CloudWatchAlarms;
3048 app.service_selected = true;
3049 app.tabs.push(crate::app::Tab {
3050 service: Service::CloudWatchAlarms,
3051 title: "CloudWatch > Alarms".to_string(),
3052 breadcrumb: "CloudWatch > Alarms".to_string(),
3053 });
3054
3055 assert_eq!(app.breadcrumbs(), "CloudWatch > Alarms");
3058 }
3059
3060 #[test]
3061 fn test_preferences_headers_use_cyan_underline() {
3062 let header_style = Style::default()
3064 .fg(Color::Cyan)
3065 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
3066 assert_eq!(header_style.fg, Some(Color::Cyan));
3067 assert!(header_style.add_modifier.contains(Modifier::BOLD));
3068 assert!(header_style.add_modifier.contains(Modifier::UNDERLINED));
3069
3070 let header_text = "Columns";
3072 assert!(!header_text.contains("═"));
3073 }
3074
3075 #[test]
3076 fn test_alarm_pagination_shows_actual_pages() {
3077 let page_size = 10;
3079 let total_items = 25;
3080 let total_pages = (total_items + page_size - 1) / page_size;
3081 let current_page = 1;
3082
3083 let pagination = format!("Page {} of {}", current_page, total_pages);
3084 assert_eq!(pagination, "Page 1 of 3");
3085 assert!(!pagination.contains("[1]"));
3086 assert!(!pagination.contains("[2]"));
3087 }
3088
3089 #[test]
3090 fn test_mode_indicator_uses_insert_not_input() {
3091 let mode_text = " INSERT ";
3093 assert_eq!(mode_text, " INSERT ");
3094 assert_ne!(mode_text, " INPUT ");
3095 }
3096
3097 #[test]
3098 fn test_service_picker_shows_insert_mode_when_typing() {
3099 let mut app = test_app();
3101 app.mode = Mode::ServicePicker;
3102 app.service_picker.filter = "cloud".to_string();
3103
3104 assert!(!app.service_picker.filter.is_empty());
3106 }
3107
3108 #[test]
3109 fn test_log_events_no_horizontal_scrollbar() {
3110 let app = test_app();
3114
3115 assert_eq!(app.cw_log_event_visible_column_ids.len(), 2);
3118
3119 assert_eq!(app.log_groups_state.event_horizontal_scroll, 0);
3121 }
3122
3123 #[test]
3124 fn test_log_events_expansion_stays_visible_when_scrolling() {
3125 let mut app = test_app();
3128
3129 app.log_groups_state.expanded_event = Some(0);
3131 app.log_groups_state.event_scroll_offset = 0;
3132
3133 app.log_groups_state.event_scroll_offset = 1;
3135
3136 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3138 }
3139
3140 #[test]
3141 fn test_log_events_right_arrow_expands() {
3142 let mut app = test_app();
3143 app.current_service = Service::CloudWatchLogGroups;
3144 app.service_selected = true;
3145 app.view_mode = ViewMode::Events;
3146
3147 app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3148 timestamp: chrono::Utc::now(),
3149 message: "Test log message".to_string(),
3150 }];
3151 app.log_groups_state.event_scroll_offset = 0;
3152
3153 assert_eq!(app.log_groups_state.expanded_event, None);
3154
3155 app.handle_action(Action::NextPane);
3157 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3158 }
3159
3160 #[test]
3161 fn test_log_events_left_arrow_collapses() {
3162 let mut app = test_app();
3163 app.current_service = Service::CloudWatchLogGroups;
3164 app.service_selected = true;
3165 app.view_mode = ViewMode::Events;
3166
3167 app.log_groups_state.log_events = vec![rusticity_core::LogEvent {
3168 timestamp: chrono::Utc::now(),
3169 message: "Test log message".to_string(),
3170 }];
3171 app.log_groups_state.event_scroll_offset = 0;
3172 app.log_groups_state.expanded_event = Some(0);
3173
3174 app.handle_action(Action::PrevPane);
3176 assert_eq!(app.log_groups_state.expanded_event, None);
3177 }
3178
3179 #[test]
3180 fn test_log_events_expanded_content_replaces_tabs() {
3181 let message_with_tabs = "[INFO]\t2025-10-22T13:41:37.601Z\tb2227e1c";
3183 let cleaned = message_with_tabs.replace('\t', " ");
3184
3185 assert!(!cleaned.contains('\t'));
3186 assert!(cleaned.contains(" "));
3187 assert_eq!(cleaned, "[INFO] 2025-10-22T13:41:37.601Z b2227e1c");
3188 }
3189
3190 #[test]
3191 fn test_log_events_navigation_skips_expanded_overlay() {
3192 let mut app = test_app();
3195
3196 app.log_groups_state.expanded_event = Some(0);
3198 app.log_groups_state.event_scroll_offset = 0;
3199
3200 app.log_groups_state.event_scroll_offset = 1;
3202
3203 assert_eq!(app.log_groups_state.event_scroll_offset, 1);
3205
3206 assert_eq!(app.log_groups_state.expanded_event, Some(0));
3208 }
3209
3210 #[test]
3211 fn test_log_events_empty_rows_reserve_space_for_overlay() {
3212 let message = "Long message that will wrap across multiple lines when expanded";
3215 let max_width = 50;
3216
3217 let full_line = format!("Message: {}", message);
3219 let line_count = full_line.len().div_ceil(max_width);
3220
3221 assert!(line_count >= 2);
3223
3224 }
3227
3228 #[test]
3229 fn test_preferences_title_no_hints() {
3230 let s3_title = " Preferences ";
3233 let events_title = " Preferences ";
3234 let alarms_title = " Preferences ";
3235
3236 assert_eq!(s3_title.trim(), "Preferences");
3237 assert_eq!(events_title.trim(), "Preferences");
3238 assert_eq!(alarms_title.trim(), "Preferences");
3239
3240 assert!(!s3_title.contains("Space"));
3242 assert!(!events_title.contains("Space"));
3243 assert!(!alarms_title.contains("Tab"));
3244 }
3245
3246 #[test]
3247 fn test_page_navigation_works_for_events() {
3248 let mut app = test_app();
3250 app.view_mode = ViewMode::Events;
3251
3252 app.log_groups_state.event_scroll_offset = 0;
3254
3255 let page = 2;
3257 let page_size = 20;
3258 let target_index = (page - 1) * page_size;
3259
3260 assert_eq!(target_index, 20);
3261
3262 app.page_input.clear();
3264 assert!(app.page_input.is_empty());
3265 }
3266
3267 #[test]
3268 fn test_status_bar_shows_tab_hint_for_alarms_preferences() {
3269 let app = test_app();
3272
3273 assert_eq!(app.current_service, Service::CloudWatchLogGroups);
3276
3277 }
3280
3281 #[test]
3282 fn test_column_selector_shows_correct_columns_per_service() {
3283 use crate::app::Service;
3284
3285 let mut app = test_app();
3287 app.current_service = Service::S3Buckets;
3288 let bucket_col_names: Vec<String> = app
3289 .s3_bucket_column_ids
3290 .iter()
3291 .filter_map(|id| crate::s3::BucketColumn::from_id(id).map(|c| c.name()))
3292 .collect();
3293 assert_eq!(bucket_col_names, vec!["Name", "Region", "Creation date"]);
3294
3295 app.current_service = Service::CloudWatchLogGroups;
3297 let log_col_names: Vec<String> = app
3298 .cw_log_group_column_ids
3299 .iter()
3300 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
3301 .collect();
3302 assert_eq!(
3303 log_col_names,
3304 vec![
3305 "Log group",
3306 "Log class",
3307 "Retention",
3308 "Stored bytes",
3309 "Creation time",
3310 "ARN"
3311 ]
3312 );
3313
3314 app.current_service = Service::CloudWatchAlarms;
3316 assert!(!app.cw_alarm_column_ids.is_empty());
3317 if let Some(col) = AlarmColumn::from_id(app.cw_alarm_column_ids[0]) {
3318 assert!(col.name().contains("Name") || col.name().contains("Alarm"));
3319 }
3320 }
3321
3322 #[test]
3323 fn test_log_groups_preferences_shows_all_six_columns() {
3324 use crate::app::Service;
3325
3326 let mut app = test_app();
3327 app.current_service = Service::CloudWatchLogGroups;
3328
3329 assert_eq!(app.cw_log_group_column_ids.len(), 6);
3331
3332 let col_names: Vec<String> = app
3334 .cw_log_group_column_ids
3335 .iter()
3336 .filter_map(|id| LogGroupColumn::from_id(id).map(|c| c.name().to_string()))
3337 .collect();
3338 assert!(col_names.iter().any(|n| n == "Log group"));
3339 assert!(col_names.iter().any(|n| n == "Log class"));
3340 assert!(col_names.iter().any(|n| n == "Retention"));
3341 assert!(col_names.iter().any(|n| n == "Stored bytes"));
3342 assert!(col_names.iter().any(|n| n == "Creation time"));
3343 assert!(col_names.iter().any(|n| n == "ARN"));
3344 }
3345
3346 #[test]
3347 fn test_stream_preferences_shows_all_columns() {
3348 use crate::app::ViewMode;
3349
3350 let mut app = test_app();
3351 app.view_mode = ViewMode::Detail;
3352
3353 assert!(!app.cw_log_stream_column_ids.is_empty());
3355 assert_eq!(app.cw_log_stream_column_ids.len(), 7);
3356 }
3357
3358 #[test]
3359 fn test_event_preferences_shows_all_columns() {
3360 use crate::app::ViewMode;
3361
3362 let mut app = test_app();
3363 app.view_mode = ViewMode::Events;
3364
3365 assert!(!app.cw_log_event_column_ids.is_empty());
3367 assert_eq!(app.cw_log_event_column_ids.len(), 5);
3368 }
3369
3370 #[test]
3371 fn test_alarm_preferences_shows_all_columns() {
3372 use crate::app::Service;
3373
3374 let mut app = test_app();
3375 app.current_service = Service::CloudWatchAlarms;
3376
3377 assert!(!app.cw_alarm_column_ids.is_empty());
3379 assert_eq!(app.cw_alarm_column_ids.len(), 16);
3380 }
3381
3382 #[test]
3383 fn test_column_selector_has_scrollbar() {
3384 let item_count = 6; assert!(item_count > 0);
3388
3389 }
3392
3393 #[test]
3394 fn test_preferences_scrollbar_only_when_needed() {
3395 let item_count = 6;
3397 let height = (item_count as u16 + 2).max(8); let max_height_fits = 20; let max_height_doesnt_fit = 5; let needs_scrollbar_fits = height > max_height_fits;
3403 assert!(!needs_scrollbar_fits);
3404
3405 let needs_scrollbar_doesnt_fit = height > max_height_doesnt_fit;
3407 assert!(needs_scrollbar_doesnt_fit);
3408 }
3409
3410 #[test]
3411 fn test_preferences_height_no_extra_padding() {
3412 let item_count = 6;
3414 let height = (item_count as u16 + 2).max(8);
3415 assert_eq!(height, 8); assert_ne!(height, 10); }
3420
3421 #[test]
3422 fn test_preferences_uses_absolute_sizing() {
3423 let width = 50u16; let height = 10u16; assert!(width <= 100); assert!(height <= 50); }
3432
3433 #[test]
3434 fn test_profile_picker_shows_sort_indicator() {
3435 let sort_column = "Profile";
3437 let sort_direction = "ASC";
3438
3439 assert_eq!(sort_column, "Profile");
3440 assert_eq!(sort_direction, "ASC");
3441
3442 let arrow = if sort_direction == "ASC" {
3444 " ↑"
3445 } else {
3446 " ↓"
3447 };
3448 assert_eq!(arrow, " ↑");
3449 }
3450
3451 #[test]
3452 fn test_session_picker_shows_sort_indicator() {
3453 let sort_column = "Timestamp";
3455 let sort_direction = "DESC";
3456
3457 assert_eq!(sort_column, "Timestamp");
3458 assert_eq!(sort_direction, "DESC");
3459
3460 let arrow = if sort_direction == "ASC" {
3462 " ↑"
3463 } else {
3464 " ↓"
3465 };
3466 assert_eq!(arrow, " ↓");
3467 }
3468
3469 #[test]
3470 fn test_profile_picker_sorted_ascending() {
3471 let mut app = test_app_no_region();
3472 app.available_profiles = vec![
3473 crate::app::AwsProfile {
3474 name: "zebra".to_string(),
3475 region: None,
3476 account: None,
3477 role_arn: None,
3478 source_profile: None,
3479 },
3480 crate::app::AwsProfile {
3481 name: "alpha".to_string(),
3482 region: None,
3483 account: None,
3484 role_arn: None,
3485 source_profile: None,
3486 },
3487 ];
3488
3489 let filtered = app.get_filtered_profiles();
3490 assert_eq!(filtered[0].name, "alpha");
3491 assert_eq!(filtered[1].name, "zebra");
3492 }
3493
3494 #[test]
3495 fn test_session_picker_sorted_descending() {
3496 let mut app = test_app_no_region();
3497 app.sessions = vec![
3499 crate::session::Session {
3500 id: "2".to_string(),
3501 timestamp: "2024-01-02 10:00:00 UTC".to_string(),
3502 profile: "new".to_string(),
3503 region: "us-east-1".to_string(),
3504 account_id: "123".to_string(),
3505 role_arn: String::new(),
3506 tabs: vec![],
3507 },
3508 crate::session::Session {
3509 id: "1".to_string(),
3510 timestamp: "2024-01-01 10:00:00 UTC".to_string(),
3511 profile: "old".to_string(),
3512 region: "us-east-1".to_string(),
3513 account_id: "123".to_string(),
3514 role_arn: String::new(),
3515 tabs: vec![],
3516 },
3517 ];
3518
3519 let filtered = app.get_filtered_sessions();
3520 assert_eq!(filtered[0].profile, "new");
3522 assert_eq!(filtered[1].profile, "old");
3523 }
3524
3525 #[test]
3526 fn test_ecr_encryption_type_aes256_renders_as_aes_dash_256() {
3527 let repo = EcrRepository {
3528 name: "test-repo".to_string(),
3529 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3530 created_at: "2024-01-01".to_string(),
3531 tag_immutability: "MUTABLE".to_string(),
3532 encryption_type: "AES256".to_string(),
3533 };
3534
3535 let formatted = match repo.encryption_type.as_str() {
3536 "AES256" => "AES-256".to_string(),
3537 "KMS" => "KMS".to_string(),
3538 other => other.to_string(),
3539 };
3540
3541 assert_eq!(formatted, "AES-256");
3542 }
3543
3544 #[test]
3545 fn test_ecr_encryption_type_kms_unchanged() {
3546 let repo = EcrRepository {
3547 name: "test-repo".to_string(),
3548 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3549 created_at: "2024-01-01".to_string(),
3550 tag_immutability: "MUTABLE".to_string(),
3551 encryption_type: "KMS".to_string(),
3552 };
3553
3554 let formatted = match repo.encryption_type.as_str() {
3555 "AES256" => "AES-256".to_string(),
3556 "KMS" => "KMS".to_string(),
3557 other => other.to_string(),
3558 };
3559
3560 assert_eq!(formatted, "KMS");
3561 }
3562
3563 #[test]
3564 fn test_ecr_repo_filter_active_removes_table_focus() {
3565 let mut app = test_app_no_region();
3566 app.current_service = Service::EcrRepositories;
3567 app.mode = Mode::FilterInput;
3568 app.ecr_state.repositories.items = vec![EcrRepository {
3569 name: "test-repo".to_string(),
3570 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
3571 created_at: "2024-01-01".to_string(),
3572 tag_immutability: "MUTABLE".to_string(),
3573 encryption_type: "AES256".to_string(),
3574 }];
3575
3576 assert_eq!(app.mode, Mode::FilterInput);
3578 }
3580
3581 #[test]
3582 fn test_ecr_image_filter_active_removes_table_focus() {
3583 let mut app = test_app_no_region();
3584 app.current_service = Service::EcrRepositories;
3585 app.ecr_state.current_repository = Some("test-repo".to_string());
3586 app.mode = Mode::FilterInput;
3587 app.ecr_state.images.items = vec![EcrImage {
3588 tag: "v1.0.0".to_string(),
3589 artifact_type: "application/vnd.docker.container.image.v1+json".to_string(),
3590 pushed_at: "2024-01-01".to_string(),
3591 size_bytes: 104857600,
3592 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo:v1.0.0".to_string(),
3593 digest: "sha256:abc123".to_string(),
3594 last_pull_time: "2024-01-02".to_string(),
3595 }];
3596
3597 assert_eq!(app.mode, Mode::FilterInput);
3599 }
3601
3602 #[test]
3603 fn test_ecr_filter_escape_returns_to_normal_mode() {
3604 let mut app = test_app_no_region();
3605 app.current_service = Service::EcrRepositories;
3606 app.mode = Mode::FilterInput;
3607 app.ecr_state.repositories.filter = "test".to_string();
3608
3609 app.handle_action(crate::keymap::Action::CloseMenu);
3611
3612 assert_eq!(app.mode, Mode::Normal);
3613 }
3614
3615 #[test]
3616 fn test_ecr_repos_no_scrollbar_when_all_fit() {
3617 let mut app = test_app_no_region();
3619 app.current_service = Service::EcrRepositories;
3620 app.ecr_state.repositories.items = (0..50)
3621 .map(|i| EcrRepository {
3622 name: format!("repo{}", i),
3623 uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
3624 created_at: "2024-01-01".to_string(),
3625 tag_immutability: "MUTABLE".to_string(),
3626 encryption_type: "AES256".to_string(),
3627 })
3628 .collect();
3629
3630 let row_count = 50;
3633 let typical_area_height: u16 = 60;
3634 let available_height = typical_area_height.saturating_sub(3);
3635
3636 assert!(
3637 row_count <= available_height as usize,
3638 "50 repos should fit without scrollbar"
3639 );
3640 }
3641
3642 #[test]
3643 fn test_lambda_default_columns() {
3644 let app = test_app_no_region();
3645
3646 assert_eq!(app.lambda_state.function_visible_column_ids.len(), 6);
3647 assert_eq!(
3648 app.lambda_state.function_visible_column_ids[0],
3649 "column.lambda.function.name"
3650 );
3651 assert_eq!(
3652 app.lambda_state.function_visible_column_ids[1],
3653 "column.lambda.function.runtime"
3654 );
3655 assert_eq!(
3656 app.lambda_state.function_visible_column_ids[2],
3657 "column.lambda.function.code_size"
3658 );
3659 assert_eq!(
3660 app.lambda_state.function_visible_column_ids[3],
3661 "column.lambda.function.memory_mb"
3662 );
3663 assert_eq!(
3664 app.lambda_state.function_visible_column_ids[4],
3665 "column.lambda.function.timeout_seconds"
3666 );
3667 assert_eq!(
3668 app.lambda_state.function_visible_column_ids[5],
3669 "column.lambda.function.last_modified"
3670 );
3671 }
3672
3673 #[test]
3674 fn test_lambda_all_columns_available() {
3675 let all_columns = lambda::FunctionColumn::ids();
3676
3677 assert_eq!(all_columns.len(), 9);
3678 assert!(all_columns.contains(&"column.lambda.function.name"));
3679 assert!(all_columns.contains(&"column.lambda.function.description"));
3680 assert!(all_columns.contains(&"column.lambda.function.package_type"));
3681 assert!(all_columns.contains(&"column.lambda.function.runtime"));
3682 assert!(all_columns.contains(&"column.lambda.function.architecture"));
3683 assert!(all_columns.contains(&"column.lambda.function.code_size"));
3684 assert!(all_columns.contains(&"column.lambda.function.memory_mb"));
3685 assert!(all_columns.contains(&"column.lambda.function.timeout_seconds"));
3686 assert!(all_columns.contains(&"column.lambda.function.last_modified"));
3687 }
3688
3689 #[test]
3690 fn test_lambda_filter_active_removes_table_focus() {
3691 let mut app = test_app_no_region();
3692 app.current_service = Service::LambdaFunctions;
3693 app.mode = Mode::FilterInput;
3694 app.lambda_state.table.items = vec![lambda::Function {
3695 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3696 application: None,
3697 name: "test-function".to_string(),
3698 description: "Test function".to_string(),
3699 package_type: "Zip".to_string(),
3700 runtime: "python3.12".to_string(),
3701 architecture: "x86_64".to_string(),
3702 code_size: 1024,
3703 code_sha256: "test-sha256".to_string(),
3704 memory_mb: 128,
3705 timeout_seconds: 3,
3706 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3707 layers: vec![],
3708 }];
3709
3710 assert_eq!(app.mode, Mode::FilterInput);
3711 }
3712
3713 #[test]
3714 fn test_lambda_default_page_size() {
3715 let app = test_app_no_region();
3716
3717 assert_eq!(app.lambda_state.table.page_size, PageSize::Fifty);
3718 assert_eq!(app.lambda_state.table.page_size.value(), 50);
3719 }
3720
3721 #[test]
3722 fn test_lambda_pagination() {
3723 let mut app = test_app_no_region();
3724 app.current_service = Service::LambdaFunctions;
3725 app.lambda_state.table.page_size = PageSize::Ten;
3726 app.lambda_state.table.items = (0..25)
3727 .map(|i| crate::app::LambdaFunction {
3728 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3729 application: None,
3730 name: format!("function-{}", i),
3731 description: format!("Function {}", i),
3732 package_type: "Zip".to_string(),
3733 runtime: "python3.12".to_string(),
3734 architecture: "x86_64".to_string(),
3735 code_size: 1024,
3736 code_sha256: "test-sha256".to_string(),
3737 memory_mb: 128,
3738 timeout_seconds: 3,
3739 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3740 layers: vec![],
3741 })
3742 .collect();
3743
3744 let page_size = app.lambda_state.table.page_size.value();
3745 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3746
3747 assert_eq!(page_size, 10);
3748 assert_eq!(total_pages, 3);
3749 }
3750
3751 #[test]
3752 fn test_lambda_filter_by_name() {
3753 let mut app = test_app_no_region();
3754 app.lambda_state.table.items = vec![
3755 crate::app::LambdaFunction {
3756 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3757 application: None,
3758 name: "api-handler".to_string(),
3759 description: "API handler".to_string(),
3760 package_type: "Zip".to_string(),
3761 runtime: "python3.12".to_string(),
3762 architecture: "x86_64".to_string(),
3763 code_size: 1024,
3764 code_sha256: "test-sha256".to_string(),
3765 memory_mb: 128,
3766 timeout_seconds: 3,
3767 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3768 layers: vec![],
3769 },
3770 crate::app::LambdaFunction {
3771 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3772 application: None,
3773 name: "data-processor".to_string(),
3774 description: "Data processor".to_string(),
3775 package_type: "Zip".to_string(),
3776 runtime: "nodejs20.x".to_string(),
3777 architecture: "arm64".to_string(),
3778 code_size: 2048,
3779 code_sha256: "test-sha256".to_string(),
3780 memory_mb: 256,
3781 timeout_seconds: 30,
3782 last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
3783 layers: vec![],
3784 },
3785 ];
3786 app.lambda_state.table.filter = "api".to_string();
3787
3788 let filtered: Vec<_> = app
3789 .lambda_state
3790 .table
3791 .items
3792 .iter()
3793 .filter(|f| {
3794 app.lambda_state.table.filter.is_empty()
3795 || f.name
3796 .to_lowercase()
3797 .contains(&app.lambda_state.table.filter.to_lowercase())
3798 || f.description
3799 .to_lowercase()
3800 .contains(&app.lambda_state.table.filter.to_lowercase())
3801 || f.runtime
3802 .to_lowercase()
3803 .contains(&app.lambda_state.table.filter.to_lowercase())
3804 })
3805 .collect();
3806
3807 assert_eq!(filtered.len(), 1);
3808 assert_eq!(filtered[0].name, "api-handler");
3809 }
3810
3811 #[test]
3812 fn test_lambda_filter_by_runtime() {
3813 let mut app = test_app_no_region();
3814 app.lambda_state.table.items = vec![
3815 crate::app::LambdaFunction {
3816 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3817 application: None,
3818 name: "python-func".to_string(),
3819 description: "Python function".to_string(),
3820 package_type: "Zip".to_string(),
3821 runtime: "python3.12".to_string(),
3822 architecture: "x86_64".to_string(),
3823 code_size: 1024,
3824 code_sha256: "test-sha256".to_string(),
3825 memory_mb: 128,
3826 timeout_seconds: 3,
3827 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3828 layers: vec![],
3829 },
3830 crate::app::LambdaFunction {
3831 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3832 application: None,
3833 name: "node-func".to_string(),
3834 description: "Node function".to_string(),
3835 package_type: "Zip".to_string(),
3836 runtime: "nodejs20.x".to_string(),
3837 architecture: "arm64".to_string(),
3838 code_size: 2048,
3839 code_sha256: "test-sha256".to_string(),
3840 memory_mb: 256,
3841 timeout_seconds: 30,
3842 last_modified: "2024-01-02T00:00:00.000+0000".to_string(),
3843 layers: vec![],
3844 },
3845 ];
3846 app.lambda_state.table.filter = "python".to_string();
3847
3848 let filtered: Vec<_> = app
3849 .lambda_state
3850 .table
3851 .items
3852 .iter()
3853 .filter(|f| {
3854 app.lambda_state.table.filter.is_empty()
3855 || f.name
3856 .to_lowercase()
3857 .contains(&app.lambda_state.table.filter.to_lowercase())
3858 || f.description
3859 .to_lowercase()
3860 .contains(&app.lambda_state.table.filter.to_lowercase())
3861 || f.runtime
3862 .to_lowercase()
3863 .contains(&app.lambda_state.table.filter.to_lowercase())
3864 })
3865 .collect();
3866
3867 assert_eq!(filtered.len(), 1);
3868 assert_eq!(filtered[0].runtime, "python3.12");
3869 }
3870
3871 #[test]
3872 fn test_lambda_page_size_changes_in_preferences() {
3873 let mut app = test_app_no_region();
3874 app.current_service = Service::LambdaFunctions;
3875 app.lambda_state.table.page_size = PageSize::Fifty;
3876
3877 app.mode = Mode::ColumnSelector;
3879 app.column_selector_index = 12; app.handle_action(crate::keymap::Action::ToggleColumn);
3882
3883 assert_eq!(app.lambda_state.table.page_size, PageSize::Ten);
3884 }
3885
3886 #[test]
3887 fn test_lambda_preferences_shows_page_sizes() {
3888 let app = test_app_no_region();
3889 let mut app = app;
3890 app.current_service = Service::LambdaFunctions;
3891
3892 let page_sizes = vec![
3894 PageSize::Ten,
3895 PageSize::TwentyFive,
3896 PageSize::Fifty,
3897 PageSize::OneHundred,
3898 ];
3899
3900 for size in page_sizes {
3901 app.lambda_state.table.page_size = size;
3902 assert_eq!(app.lambda_state.table.page_size, size);
3903 }
3904 }
3905
3906 #[test]
3907 fn test_lambda_pagination_respects_page_size() {
3908 let mut app = test_app_no_region();
3909 app.current_service = Service::LambdaFunctions;
3910 app.lambda_state.table.items = (0..100)
3911 .map(|i| crate::app::LambdaFunction {
3912 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3913 application: None,
3914 name: format!("function-{}", i),
3915 description: format!("Function {}", i),
3916 package_type: "Zip".to_string(),
3917 runtime: "python3.12".to_string(),
3918 architecture: "x86_64".to_string(),
3919 code_size: 1024,
3920 code_sha256: "test-sha256".to_string(),
3921 memory_mb: 128,
3922 timeout_seconds: 3,
3923 last_modified: "2024-01-01T00:00:00.000+0000".to_string(),
3924 layers: vec![],
3925 })
3926 .collect();
3927
3928 app.lambda_state.table.page_size = PageSize::Ten;
3930 let page_size = app.lambda_state.table.page_size.value();
3931 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3932 assert_eq!(page_size, 10);
3933 assert_eq!(total_pages, 10);
3934
3935 app.lambda_state.table.page_size = PageSize::TwentyFive;
3937 let page_size = app.lambda_state.table.page_size.value();
3938 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3939 assert_eq!(page_size, 25);
3940 assert_eq!(total_pages, 4);
3941
3942 app.lambda_state.table.page_size = PageSize::Fifty;
3944 let page_size = app.lambda_state.table.page_size.value();
3945 let total_pages = app.lambda_state.table.items.len().div_ceil(page_size);
3946 assert_eq!(page_size, 50);
3947 assert_eq!(total_pages, 2);
3948 }
3949
3950 #[test]
3951 fn test_lambda_next_preferences_cycles_sections() {
3952 let mut app = test_app_no_region();
3953 app.current_service = Service::LambdaFunctions;
3954 app.mode = Mode::ColumnSelector;
3955
3956 app.column_selector_index = 0;
3958 app.handle_action(crate::keymap::Action::NextPreferences);
3959
3960 assert_eq!(app.column_selector_index, 11);
3962
3963 app.handle_action(crate::keymap::Action::NextPreferences);
3965 assert_eq!(app.column_selector_index, 0);
3966 }
3967
3968 #[test]
3969 fn test_lambda_drill_down_on_enter() {
3970 let mut app = test_app_no_region();
3971 app.current_service = Service::LambdaFunctions;
3972 app.service_selected = true;
3973 app.mode = Mode::Normal;
3974 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
3975 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
3976 application: None,
3977 name: "test-function".to_string(),
3978 description: "Test function".to_string(),
3979 package_type: "Zip".to_string(),
3980 runtime: "python3.12".to_string(),
3981 architecture: "x86_64".to_string(),
3982 code_size: 1024,
3983 code_sha256: "test-sha256".to_string(),
3984 memory_mb: 128,
3985 timeout_seconds: 3,
3986 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
3987 layers: vec![],
3988 }];
3989 app.lambda_state.table.selected = 0;
3990
3991 app.handle_action(crate::keymap::Action::Select);
3993
3994 assert_eq!(
3995 app.lambda_state.current_function,
3996 Some("test-function".to_string())
3997 );
3998 assert_eq!(
3999 app.lambda_state.detail_tab,
4000 crate::app::LambdaDetailTab::Code
4001 );
4002 }
4003
4004 #[test]
4005 fn test_lambda_go_back_from_detail() {
4006 let mut app = test_app_no_region();
4007 app.current_service = Service::LambdaFunctions;
4008 app.lambda_state.current_function = Some("test-function".to_string());
4009
4010 app.handle_action(crate::keymap::Action::GoBack);
4011
4012 assert_eq!(app.lambda_state.current_function, None);
4013 }
4014
4015 #[test]
4016 fn test_lambda_detail_tab_cycling() {
4017 let mut app = test_app_no_region();
4018 app.current_service = Service::LambdaFunctions;
4019 app.lambda_state.current_function = Some("test-function".to_string());
4020 app.lambda_state.detail_tab = crate::app::LambdaDetailTab::Code;
4021
4022 app.handle_action(crate::keymap::Action::NextDetailTab);
4023 assert_eq!(
4024 app.lambda_state.detail_tab,
4025 crate::app::LambdaDetailTab::Configuration
4026 );
4027
4028 app.handle_action(crate::keymap::Action::NextDetailTab);
4029 assert_eq!(
4030 app.lambda_state.detail_tab,
4031 crate::app::LambdaDetailTab::Aliases
4032 );
4033
4034 app.handle_action(crate::keymap::Action::NextDetailTab);
4035 assert_eq!(
4036 app.lambda_state.detail_tab,
4037 crate::app::LambdaDetailTab::Versions
4038 );
4039
4040 app.handle_action(crate::keymap::Action::NextDetailTab);
4041 assert_eq!(
4042 app.lambda_state.detail_tab,
4043 crate::app::LambdaDetailTab::Code
4044 );
4045 }
4046
4047 #[test]
4048 fn test_lambda_breadcrumbs_with_function_name() {
4049 let mut app = test_app_no_region();
4050 app.current_service = Service::LambdaFunctions;
4051 app.service_selected = true;
4052
4053 let breadcrumb = app.breadcrumbs();
4055 assert_eq!(breadcrumb, "Lambda > Functions");
4056
4057 app.lambda_state.current_function = Some("my-function".to_string());
4059 let breadcrumb = app.breadcrumbs();
4060 assert_eq!(breadcrumb, "Lambda > my-function");
4061 }
4062
4063 #[test]
4064 fn test_lambda_console_url() {
4065 let mut app = test_app_no_region();
4066 app.current_service = Service::LambdaFunctions;
4067 app.config.region = "us-east-1".to_string();
4068
4069 let url = app.get_console_url();
4071 assert_eq!(
4072 url,
4073 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions"
4074 );
4075
4076 app.lambda_state.current_function = Some("my-function".to_string());
4078 let url = app.get_console_url();
4079 assert_eq!(
4080 url,
4081 "https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/my-function"
4082 );
4083 }
4084
4085 #[test]
4086 fn test_lambda_last_modified_format() {
4087 let func = crate::app::LambdaFunction {
4088 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4089 application: None,
4090 name: "test-function".to_string(),
4091 description: "Test function".to_string(),
4092 package_type: "Zip".to_string(),
4093 runtime: "python3.12".to_string(),
4094 architecture: "x86_64".to_string(),
4095 code_size: 1024,
4096 code_sha256: "test-sha256".to_string(),
4097 memory_mb: 128,
4098 timeout_seconds: 3,
4099 last_modified: "2024-01-01 12:30:45 (UTC)".to_string(),
4100 layers: vec![],
4101 };
4102
4103 assert!(func.last_modified.contains("(UTC)"));
4105 assert!(func.last_modified.contains("2024-01-01"));
4106 }
4107
4108 #[test]
4109 fn test_lambda_expand_on_right_arrow() {
4110 let mut app = test_app_no_region();
4111 app.current_service = Service::LambdaFunctions;
4112 app.service_selected = true;
4113 app.mode = Mode::Normal;
4114 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4115 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4116 application: None,
4117 name: "test-function".to_string(),
4118 description: "Test function".to_string(),
4119 package_type: "Zip".to_string(),
4120 runtime: "python3.12".to_string(),
4121 architecture: "x86_64".to_string(),
4122 code_size: 1024,
4123 code_sha256: "test-sha256".to_string(),
4124 memory_mb: 128,
4125 timeout_seconds: 3,
4126 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4127 layers: vec![],
4128 }];
4129 app.lambda_state.table.selected = 0;
4130
4131 app.handle_action(crate::keymap::Action::NextPane);
4132
4133 assert_eq!(app.lambda_state.table.expanded_item, Some(0));
4134 }
4135
4136 #[test]
4137 fn test_lambda_collapse_on_left_arrow() {
4138 let mut app = test_app_no_region();
4139 app.current_service = Service::LambdaFunctions;
4140 app.service_selected = true;
4141 app.mode = Mode::Normal;
4142 app.lambda_state.current_function = None; app.lambda_state.table.expanded_item = Some(0);
4144
4145 app.handle_action(crate::keymap::Action::PrevPane);
4146
4147 assert_eq!(app.lambda_state.table.expanded_item, None);
4148 }
4149
4150 #[test]
4151 fn test_lambda_filter_activation() {
4152 let mut app = test_app_no_region();
4153 app.current_service = Service::LambdaFunctions;
4154 app.service_selected = true;
4155 app.mode = Mode::Normal;
4156
4157 app.handle_action(crate::keymap::Action::StartFilter);
4158
4159 assert_eq!(app.mode, Mode::FilterInput);
4160 }
4161
4162 #[test]
4163 fn test_lambda_filter_backspace() {
4164 let mut app = test_app_no_region();
4165 app.current_service = Service::LambdaFunctions;
4166 app.mode = Mode::FilterInput;
4167 app.lambda_state.table.filter = "test".to_string();
4168
4169 app.handle_action(crate::keymap::Action::FilterBackspace);
4170
4171 assert_eq!(app.lambda_state.table.filter, "tes");
4172 }
4173
4174 #[test]
4175 fn test_lambda_sorted_by_last_modified_desc() {
4176 let func1 = crate::app::LambdaFunction {
4177 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4178 application: None,
4179 name: "func1".to_string(),
4180 description: String::new(),
4181 package_type: "Zip".to_string(),
4182 runtime: "python3.12".to_string(),
4183 architecture: "x86_64".to_string(),
4184 code_size: 1024,
4185 code_sha256: "test-sha256".to_string(),
4186 memory_mb: 128,
4187 timeout_seconds: 3,
4188 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4189 layers: vec![],
4190 };
4191 let func2 = crate::app::LambdaFunction {
4192 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4193 application: None,
4194 name: "func2".to_string(),
4195 description: String::new(),
4196 package_type: "Zip".to_string(),
4197 runtime: "python3.12".to_string(),
4198 architecture: "x86_64".to_string(),
4199 code_size: 1024,
4200 code_sha256: "test-sha256".to_string(),
4201 memory_mb: 128,
4202 timeout_seconds: 3,
4203 last_modified: "2024-12-31 00:00:00 (UTC)".to_string(),
4204 layers: vec![],
4205 };
4206
4207 let mut functions = [func1.clone(), func2.clone()].to_vec();
4208 functions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
4209
4210 assert_eq!(functions[0].name, "func2");
4212 assert_eq!(functions[1].name, "func1");
4213 }
4214
4215 #[test]
4216 fn test_lambda_code_properties_has_sha256() {
4217 let func = crate::app::LambdaFunction {
4218 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4219 application: None,
4220 name: "test-function".to_string(),
4221 description: "Test".to_string(),
4222 package_type: "Zip".to_string(),
4223 runtime: "python3.12".to_string(),
4224 architecture: "x86_64".to_string(),
4225 code_size: 2600,
4226 code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4227 memory_mb: 128,
4228 timeout_seconds: 3,
4229 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4230 layers: vec![],
4231 };
4232
4233 assert!(!func.code_sha256.is_empty());
4234 assert_eq!(
4235 func.code_sha256,
4236 "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE="
4237 );
4238 }
4239
4240 #[test]
4241 fn test_lambda_name_column_has_expand_symbol() {
4242 let func = crate::app::LambdaFunction {
4243 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4244 application: None,
4245 name: "test-function".to_string(),
4246 description: "Test".to_string(),
4247 package_type: "Zip".to_string(),
4248 runtime: "python3.12".to_string(),
4249 architecture: "x86_64".to_string(),
4250 code_size: 1024,
4251 code_sha256: "test-sha256".to_string(),
4252 memory_mb: 128,
4253 timeout_seconds: 3,
4254 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4255 layers: vec![],
4256 };
4257
4258 let symbol_collapsed = crate::ui::table::CURSOR_COLLAPSED;
4260 let rendered_collapsed = format!("{} {}", symbol_collapsed, func.name);
4261 assert!(rendered_collapsed.contains(symbol_collapsed));
4262 assert!(rendered_collapsed.contains("test-function"));
4263
4264 let symbol_expanded = crate::ui::table::CURSOR_EXPANDED;
4266 let rendered_expanded = format!("{} {}", symbol_expanded, func.name);
4267 assert!(rendered_expanded.contains(symbol_expanded));
4268 assert!(rendered_expanded.contains("test-function"));
4269
4270 assert_ne!(symbol_collapsed, symbol_expanded);
4272 }
4273
4274 #[test]
4275 fn test_lambda_last_modified_column_width() {
4276 let timestamp = "2025-10-31 08:37:46 (UTC)";
4278 assert_eq!(timestamp.len(), 25);
4279
4280 let width = 27u16;
4282 assert!(width >= timestamp.len() as u16);
4283 }
4284
4285 #[test]
4286 fn test_lambda_code_properties_has_info_and_kms_sections() {
4287 let mut app = test_app_no_region();
4288 app.current_service = Service::LambdaFunctions;
4289 app.lambda_state.current_function = Some("test-function".to_string());
4290 app.lambda_state.detail_tab = crate::app::LambdaDetailTab::Code;
4291 app.lambda_state.table.items = vec![crate::app::LambdaFunction {
4292 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4293 application: None,
4294 name: "test-function".to_string(),
4295 description: "Test".to_string(),
4296 package_type: "Zip".to_string(),
4297 runtime: "python3.12".to_string(),
4298 architecture: "x86_64".to_string(),
4299 code_size: 2600,
4300 code_sha256: "HHn6CTPhEnmSfX9I/dozcFFLQXUTDFapBAkzjVj9UxE=".to_string(),
4301 memory_mb: 128,
4302 timeout_seconds: 3,
4303 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4304 layers: vec![],
4305 }];
4306
4307 assert_eq!(
4309 app.lambda_state.detail_tab,
4310 crate::app::LambdaDetailTab::Code
4311 );
4312
4313 assert!(app.lambda_state.current_function.is_some());
4315 assert_eq!(app.lambda_state.table.items.len(), 1);
4316
4317 let func = &app.lambda_state.table.items[0];
4319 assert!(!func.code_sha256.is_empty());
4320 assert!(!func.last_modified.is_empty());
4321 assert!(func.code_size > 0);
4322 }
4323
4324 #[test]
4325 fn test_lambda_pagination_navigation() {
4326 let mut app = test_app_no_region();
4327 app.current_service = Service::LambdaFunctions;
4328 app.service_selected = true;
4329 app.mode = Mode::Normal;
4330 app.lambda_state.table.page_size = PageSize::Ten;
4331
4332 app.lambda_state.table.items = (0..25)
4334 .map(|i| crate::app::LambdaFunction {
4335 arn: "arn:aws:lambda:us-east-1:123456789012:function:test".to_string(),
4336 application: None,
4337 name: format!("function-{}", i),
4338 description: "Test".to_string(),
4339 package_type: "Zip".to_string(),
4340 runtime: "python3.12".to_string(),
4341 architecture: "x86_64".to_string(),
4342 code_size: 1024,
4343 code_sha256: "test-sha256".to_string(),
4344 memory_mb: 128,
4345 timeout_seconds: 3,
4346 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4347 layers: vec![],
4348 })
4349 .collect();
4350
4351 app.lambda_state.table.selected = 0;
4353 let page_size = app.lambda_state.table.page_size.value();
4354 let current_page = app.lambda_state.table.selected / page_size;
4355 assert_eq!(current_page, 0);
4356 assert_eq!(app.lambda_state.table.selected % page_size, 0);
4357
4358 app.lambda_state.table.selected = 10;
4360 let current_page = app.lambda_state.table.selected / page_size;
4361 assert_eq!(current_page, 1);
4362 assert_eq!(app.lambda_state.table.selected % page_size, 0);
4363
4364 app.lambda_state.table.selected = 15;
4366 let current_page = app.lambda_state.table.selected / page_size;
4367 assert_eq!(current_page, 1);
4368 assert_eq!(app.lambda_state.table.selected % page_size, 5);
4369 }
4370
4371 #[test]
4372 fn test_lambda_pagination_with_100_functions() {
4373 let mut app = test_app_no_region();
4374 app.current_service = Service::LambdaFunctions;
4375 app.service_selected = true;
4376 app.mode = Mode::Normal;
4377 app.lambda_state.table.page_size = PageSize::Fifty;
4378
4379 app.lambda_state.table.items = (0..100)
4381 .map(|i| crate::app::LambdaFunction {
4382 arn: format!("arn:aws:lambda:us-east-1:123456789012:function:func-{}", i),
4383 application: None,
4384 name: format!("function-{:03}", i),
4385 description: format!("Function {}", i),
4386 package_type: "Zip".to_string(),
4387 runtime: "python3.12".to_string(),
4388 architecture: "x86_64".to_string(),
4389 code_size: 1024 + i,
4390 code_sha256: format!("sha256-{}", i),
4391 memory_mb: 128,
4392 timeout_seconds: 3,
4393 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4394 layers: vec![],
4395 })
4396 .collect();
4397
4398 let page_size = app.lambda_state.table.page_size.value();
4399 assert_eq!(page_size, 50);
4400
4401 app.lambda_state.table.selected = 0;
4403 let current_page = app.lambda_state.table.selected / page_size;
4404 assert_eq!(current_page, 0);
4405
4406 app.lambda_state.table.selected = 49;
4407 let current_page = app.lambda_state.table.selected / page_size;
4408 assert_eq!(current_page, 0);
4409
4410 app.lambda_state.table.selected = 50;
4412 let current_page = app.lambda_state.table.selected / page_size;
4413 assert_eq!(current_page, 1);
4414
4415 app.lambda_state.table.selected = 99;
4416 let current_page = app.lambda_state.table.selected / page_size;
4417 assert_eq!(current_page, 1);
4418
4419 let filtered_count = app.lambda_state.table.items.len();
4421 let total_pages = filtered_count.div_ceil(page_size);
4422 assert_eq!(total_pages, 2);
4423 }
4424
4425 #[test]
4426 fn test_pagination_color_matches_border_color() {
4427 use ratatui::style::{Color, Style};
4428
4429 let is_filter_input = false;
4431 let pagination_style = if is_filter_input {
4432 Style::default()
4433 } else {
4434 Style::default().fg(Color::Green)
4435 };
4436 let border_style = if is_filter_input {
4437 Style::default().fg(Color::Yellow)
4438 } else {
4439 Style::default()
4440 };
4441 assert_eq!(pagination_style.fg, Some(Color::Green));
4442 assert_eq!(border_style.fg, None); let is_filter_input = true;
4446 let pagination_style = if is_filter_input {
4447 Style::default()
4448 } else {
4449 Style::default().fg(Color::Green)
4450 };
4451 let border_style = if is_filter_input {
4452 Style::default().fg(Color::Yellow)
4453 } else {
4454 Style::default()
4455 };
4456 assert_eq!(pagination_style.fg, None); assert_eq!(border_style.fg, Some(Color::Yellow));
4458 }
4459
4460 #[test]
4461 fn test_lambda_application_expansion_indicator() {
4462 let app_name = "my-application";
4464
4465 let collapsed = crate::ui::table::format_expandable(app_name, false);
4467 assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
4468 assert!(collapsed.contains(app_name));
4469
4470 let expanded = crate::ui::table::format_expandable(app_name, true);
4472 assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
4473 assert!(expanded.contains(app_name));
4474 }
4475
4476 #[test]
4477 fn test_ecr_repository_selection_uses_table_state_page_size() {
4478 let mut app = test_app_no_region();
4480 app.current_service = Service::EcrRepositories;
4481
4482 app.ecr_state.repositories.items = (0..100)
4484 .map(|i| crate::ecr::repo::Repository {
4485 name: format!("repo{}", i),
4486 uri: format!("123456789012.dkr.ecr.us-east-1.amazonaws.com/repo{}", i),
4487 created_at: "2024-01-01".to_string(),
4488 tag_immutability: "MUTABLE".to_string(),
4489 encryption_type: "AES256".to_string(),
4490 })
4491 .collect();
4492
4493 app.ecr_state.repositories.page_size = crate::common::PageSize::TwentyFive;
4495
4496 app.ecr_state.repositories.selected = 30;
4498
4499 let page_size = app.ecr_state.repositories.page_size.value();
4500 let selected_index = app.ecr_state.repositories.selected % page_size;
4501
4502 assert_eq!(page_size, 25);
4503 assert_eq!(selected_index, 5); }
4505
4506 #[test]
4507 fn test_ecr_repository_selection_indicator_visible() {
4508 let mut app = test_app_no_region();
4510 app.current_service = Service::EcrRepositories;
4511 app.mode = crate::keymap::Mode::Normal;
4512
4513 app.ecr_state.repositories.items = vec![
4514 crate::ecr::repo::Repository {
4515 name: "repo1".to_string(),
4516 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo1".to_string(),
4517 created_at: "2024-01-01".to_string(),
4518 tag_immutability: "MUTABLE".to_string(),
4519 encryption_type: "AES256".to_string(),
4520 },
4521 crate::ecr::repo::Repository {
4522 name: "repo2".to_string(),
4523 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/repo2".to_string(),
4524 created_at: "2024-01-02".to_string(),
4525 tag_immutability: "IMMUTABLE".to_string(),
4526 encryption_type: "KMS".to_string(),
4527 },
4528 ];
4529
4530 app.ecr_state.repositories.selected = 1;
4531
4532 let page_size = app.ecr_state.repositories.page_size.value();
4533 let selected_index = app.ecr_state.repositories.selected % page_size;
4534
4535 let is_active = app.mode != crate::keymap::Mode::FilterInput;
4537
4538 assert_eq!(selected_index, 1);
4539 assert!(is_active);
4540 }
4541
4542 #[test]
4543 fn test_ecr_repository_shows_expandable_indicator() {
4544 let repo = crate::ecr::repo::Repository {
4546 name: "test-repo".to_string(),
4547 uri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo".to_string(),
4548 created_at: "2024-01-01".to_string(),
4549 tag_immutability: "MUTABLE".to_string(),
4550 encryption_type: "AES256".to_string(),
4551 };
4552
4553 let collapsed = crate::ui::table::format_expandable(&repo.name, false);
4555 assert!(collapsed.contains(crate::ui::table::CURSOR_COLLAPSED));
4556 assert!(collapsed.contains("test-repo"));
4557
4558 let expanded = crate::ui::table::format_expandable(&repo.name, true);
4560 assert!(expanded.contains(crate::ui::table::CURSOR_EXPANDED));
4561 assert!(expanded.contains("test-repo"));
4562 }
4563
4564 #[test]
4565 fn test_lambda_application_expanded_status_formatting() {
4566 let app = lambda::Application {
4568 name: "test-app".to_string(),
4569 arn: "arn:aws:cloudformation:us-east-1:123456789012:stack/test-app/abc123".to_string(),
4570 description: "Test application".to_string(),
4571 status: "UpdateComplete".to_string(),
4572 last_modified: "2024-01-01 00:00:00 (UTC)".to_string(),
4573 };
4574
4575 let status_upper = app.status.to_uppercase();
4576 let formatted = if status_upper.contains("UPDATECOMPLETE")
4577 || status_upper.contains("UPDATE_COMPLETE")
4578 {
4579 "✅ Update complete"
4580 } else if status_upper.contains("CREATECOMPLETE")
4581 || status_upper.contains("CREATE_COMPLETE")
4582 {
4583 "✅ Create complete"
4584 } else {
4585 &app.status
4586 };
4587
4588 assert_eq!(formatted, "✅ Update complete");
4589
4590 let app2 = lambda::Application {
4592 status: "CreateComplete".to_string(),
4593 ..app
4594 };
4595 let status_upper = app2.status.to_uppercase();
4596 let formatted = if status_upper.contains("UPDATECOMPLETE")
4597 || status_upper.contains("UPDATE_COMPLETE")
4598 {
4599 "✅ Update complete"
4600 } else if status_upper.contains("CREATECOMPLETE")
4601 || status_upper.contains("CREATE_COMPLETE")
4602 {
4603 "✅ Create complete"
4604 } else {
4605 &app2.status
4606 };
4607 assert_eq!(formatted, "✅ Create complete");
4608 }
4609
4610 #[test]
4611 fn test_pagination_shows_1_when_empty() {
4612 let result = render_pagination_text(0, 0);
4613 assert_eq!(result, "[1]");
4614 }
4615
4616 #[test]
4617 fn test_pagination_shows_current_page() {
4618 let result = render_pagination_text(0, 3);
4619 assert_eq!(result, "[1] 2 3");
4620
4621 let result = render_pagination_text(1, 3);
4622 assert_eq!(result, "1 [2] 3");
4623 }
4624
4625 #[test]
4626 fn test_cloudformation_section_heights_match_content() {
4627 let overview_fields = 14;
4630 let overview_height = overview_fields + 2;
4631 assert_eq!(overview_height, 16);
4632
4633 let tags_empty_lines = 4;
4635 let tags_empty_height = tags_empty_lines + 2;
4636 assert_eq!(tags_empty_height, 6);
4637
4638 let policy_empty_lines = 5;
4640 let policy_empty_height = policy_empty_lines + 2;
4641 assert_eq!(policy_empty_height, 7);
4642
4643 let rollback_empty_lines = 6;
4645 let rollback_empty_height = rollback_empty_lines + 2;
4646 assert_eq!(rollback_empty_height, 8);
4647
4648 let notifications_empty_lines = 4;
4650 let notifications_empty_height = notifications_empty_lines + 2;
4651 assert_eq!(notifications_empty_height, 6);
4652 }
4653
4654 #[test]
4655 fn test_log_groups_uses_table_state() {
4656 let mut app = test_app_no_region();
4657 app.current_service = Service::CloudWatchLogGroups;
4658
4659 assert_eq!(app.log_groups_state.log_groups.items.len(), 0);
4661 assert_eq!(app.log_groups_state.log_groups.selected, 0);
4662 assert_eq!(app.log_groups_state.log_groups.filter, "");
4663 assert_eq!(
4664 app.log_groups_state.log_groups.page_size,
4665 crate::common::PageSize::Fifty
4666 );
4667 }
4668
4669 #[test]
4670 fn test_log_groups_filter_and_pagination() {
4671 let mut app = test_app_no_region();
4672 app.current_service = Service::CloudWatchLogGroups;
4673
4674 app.log_groups_state.log_groups.items = vec![
4676 rusticity_core::LogGroup {
4677 name: "/aws/lambda/function1".to_string(),
4678 creation_time: None,
4679 stored_bytes: Some(1024),
4680 retention_days: None,
4681 log_class: None,
4682 arn: None,
4683 },
4684 rusticity_core::LogGroup {
4685 name: "/aws/lambda/function2".to_string(),
4686 creation_time: None,
4687 stored_bytes: Some(2048),
4688 retention_days: None,
4689 log_class: None,
4690 arn: None,
4691 },
4692 rusticity_core::LogGroup {
4693 name: "/aws/ecs/service1".to_string(),
4694 creation_time: None,
4695 stored_bytes: Some(4096),
4696 retention_days: None,
4697 log_class: None,
4698 arn: None,
4699 },
4700 ];
4701
4702 app.log_groups_state.log_groups.filter = "lambda".to_string();
4704 let filtered = app.filtered_log_groups();
4705 assert_eq!(filtered.len(), 2);
4706
4707 let page_size = app.log_groups_state.log_groups.page_size.value();
4709 assert_eq!(page_size, 50);
4710 }
4711
4712 #[test]
4713 fn test_log_groups_expandable_indicators() {
4714 let group = rusticity_core::LogGroup {
4715 name: "/aws/lambda/test".to_string(),
4716 creation_time: None,
4717 stored_bytes: Some(1024),
4718 retention_days: None,
4719 log_class: None,
4720 arn: None,
4721 };
4722
4723 let collapsed = crate::ui::table::format_expandable(&group.name, false);
4725 assert!(collapsed.starts_with("► "));
4726 assert!(collapsed.contains("/aws/lambda/test"));
4727
4728 let expanded = crate::ui::table::format_expandable(&group.name, true);
4730 assert!(expanded.starts_with("▼ "));
4731 assert!(expanded.contains("/aws/lambda/test"));
4732 }
4733
4734 #[test]
4735 fn test_log_groups_visual_boundaries() {
4736 assert_eq!(crate::ui::table::CURSOR_COLLAPSED, "►");
4738 assert_eq!(crate::ui::table::CURSOR_EXPANDED, "▼");
4739
4740 let continuation = "│ ";
4743 let last_line = "╰ ";
4744
4745 assert_eq!(continuation, "│ ");
4746 assert_eq!(last_line, "╰ ");
4747 }
4748
4749 #[test]
4750 fn test_log_groups_right_arrow_expands() {
4751 let mut app = test_app();
4752 app.current_service = Service::CloudWatchLogGroups;
4753 app.service_selected = true;
4754 app.view_mode = ViewMode::List;
4755
4756 app.log_groups_state.log_groups.items = vec![rusticity_core::LogGroup {
4757 name: "/aws/lambda/test".to_string(),
4758 creation_time: None,
4759 stored_bytes: Some(1024),
4760 retention_days: None,
4761 log_class: None,
4762 arn: None,
4763 }];
4764 app.log_groups_state.log_groups.selected = 0;
4765
4766 assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
4767
4768 app.handle_action(Action::NextPane);
4770 assert_eq!(app.log_groups_state.log_groups.expanded_item, Some(0));
4771
4772 app.handle_action(Action::PrevPane);
4774 assert_eq!(app.log_groups_state.log_groups.expanded_item, None);
4775 }
4776
4777 #[test]
4778 fn test_log_streams_right_arrow_expands() {
4779 let mut app = test_app();
4780 app.current_service = Service::CloudWatchLogGroups;
4781 app.service_selected = true;
4782 app.view_mode = ViewMode::Detail;
4783
4784 app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
4785 name: "stream-1".to_string(),
4786 creation_time: None,
4787 last_event_time: None,
4788 }];
4789 app.log_groups_state.selected_stream = 0;
4790
4791 assert_eq!(app.log_groups_state.expanded_stream, None);
4792
4793 app.handle_action(Action::NextPane);
4795 assert_eq!(app.log_groups_state.expanded_stream, Some(0));
4796
4797 app.handle_action(Action::PrevPane);
4799 assert_eq!(app.log_groups_state.expanded_stream, None);
4800 }
4801
4802 #[test]
4803 fn test_log_events_border_style_no_double_border() {
4804 let mut app = test_app();
4807 app.current_service = Service::CloudWatchLogGroups;
4808 app.service_selected = true;
4809 app.view_mode = ViewMode::Events;
4810
4811 assert_eq!(app.view_mode, ViewMode::Events);
4814 }
4815
4816 #[test]
4817 fn test_log_group_detail_border_style_no_double_border() {
4818 let mut app = test_app();
4820 app.current_service = Service::CloudWatchLogGroups;
4821 app.service_selected = true;
4822 app.view_mode = ViewMode::Detail;
4823
4824 assert_eq!(app.view_mode, ViewMode::Detail);
4826 }
4827
4828 #[test]
4829 fn test_expansion_uses_intermediate_field_indicator() {
4830 let intermediate = "├ ";
4838 let continuation = "│ ";
4839 let last = "╰ ";
4840
4841 assert_eq!(intermediate, "├ ");
4842 assert_eq!(continuation, "│ ");
4843 assert_eq!(last, "╰ ");
4844 }
4845
4846 #[test]
4847 fn test_log_streams_expansion_renders() {
4848 let mut app = test_app();
4849 app.current_service = Service::CloudWatchLogGroups;
4850 app.service_selected = true;
4851 app.view_mode = ViewMode::Detail;
4852
4853 app.log_groups_state.log_streams = vec![rusticity_core::LogStream {
4854 name: "test-stream".to_string(),
4855 creation_time: None,
4856 last_event_time: None,
4857 }];
4858 app.log_groups_state.selected_stream = 0;
4859 app.log_groups_state.expanded_stream = Some(0);
4860
4861 assert_eq!(app.log_groups_state.expanded_stream, Some(0));
4863
4864 assert_eq!(app.log_groups_state.log_streams.len(), 1);
4866 assert_eq!(app.log_groups_state.log_streams[0].name, "test-stream");
4867 }
4868
4869 #[test]
4870 fn test_log_streams_filter_layout_single_line() {
4871 let _app = App::new_without_client("test".to_string(), Some("us-east-1".to_string()));
4874
4875 let expected_filter_height = 3;
4878 assert_eq!(expected_filter_height, 3);
4879 }
4880
4881 #[test]
4882 fn test_table_navigation_at_page_boundary() {
4883 let mut app = test_app();
4884 app.current_service = Service::CloudWatchLogGroups;
4885 app.service_selected = true;
4886 app.view_mode = ViewMode::List;
4887 app.mode = Mode::Normal;
4888
4889 for i in 0..100 {
4891 app.log_groups_state
4892 .log_groups
4893 .items
4894 .push(rusticity_core::LogGroup {
4895 name: format!("/aws/lambda/function{}", i),
4896 creation_time: None,
4897 stored_bytes: Some(1024),
4898 retention_days: None,
4899 log_class: None,
4900 arn: None,
4901 });
4902 }
4903
4904 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4906
4907 app.log_groups_state.log_groups.selected = 49;
4909
4910 app.handle_action(Action::NextItem);
4912 assert_eq!(app.log_groups_state.log_groups.selected, 50);
4913
4914 app.handle_action(Action::PrevItem);
4916 assert_eq!(app.log_groups_state.log_groups.selected, 49);
4917
4918 app.handle_action(Action::NextItem);
4920 assert_eq!(app.log_groups_state.log_groups.selected, 50);
4921
4922 app.handle_action(Action::PrevItem);
4924 assert_eq!(app.log_groups_state.log_groups.selected, 49);
4925 }
4926
4927 #[test]
4928 fn test_table_navigation_at_end() {
4929 let mut app = test_app();
4930 app.current_service = Service::CloudWatchLogGroups;
4931 app.service_selected = true;
4932 app.view_mode = ViewMode::List;
4933 app.mode = Mode::Normal;
4934
4935 for i in 0..100 {
4937 app.log_groups_state
4938 .log_groups
4939 .items
4940 .push(rusticity_core::LogGroup {
4941 name: format!("/aws/lambda/function{}", i),
4942 creation_time: None,
4943 stored_bytes: Some(1024),
4944 retention_days: None,
4945 log_class: None,
4946 arn: None,
4947 });
4948 }
4949
4950 app.log_groups_state.log_groups.selected = 99;
4952
4953 app.handle_action(Action::NextItem);
4955 assert_eq!(app.log_groups_state.log_groups.selected, 99);
4956
4957 app.handle_action(Action::PrevItem);
4959 assert_eq!(app.log_groups_state.log_groups.selected, 98);
4960 }
4961
4962 #[test]
4963 fn test_table_viewport_scrolling() {
4964 let mut app = test_app();
4965 app.current_service = Service::CloudWatchLogGroups;
4966 app.service_selected = true;
4967 app.view_mode = ViewMode::List;
4968 app.mode = Mode::Normal;
4969
4970 for i in 0..100 {
4972 app.log_groups_state
4973 .log_groups
4974 .items
4975 .push(rusticity_core::LogGroup {
4976 name: format!("/aws/lambda/function{}", i),
4977 creation_time: None,
4978 stored_bytes: Some(1024),
4979 retention_days: None,
4980 log_class: None,
4981 arn: None,
4982 });
4983 }
4984
4985 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
4987
4988 app.log_groups_state.log_groups.selected = 49;
4990 app.log_groups_state.log_groups.scroll_offset = 0;
4991
4992 app.handle_action(Action::NextItem);
4994 assert_eq!(app.log_groups_state.log_groups.selected, 50);
4995 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
4999 assert_eq!(app.log_groups_state.log_groups.selected, 49);
5000 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
5004 assert_eq!(app.log_groups_state.log_groups.selected, 48);
5005 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); for _ in 0..47 {
5009 app.handle_action(Action::PrevItem);
5010 }
5011 assert_eq!(app.log_groups_state.log_groups.selected, 1);
5012 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1); app.handle_action(Action::PrevItem);
5016 assert_eq!(app.log_groups_state.log_groups.selected, 0);
5017 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 0); }
5019
5020 #[test]
5021 fn test_table_up_from_last_row() {
5022 let mut app = test_app();
5023 app.current_service = Service::CloudWatchLogGroups;
5024 app.service_selected = true;
5025 app.view_mode = ViewMode::List;
5026 app.mode = Mode::Normal;
5027
5028 for i in 0..100 {
5030 app.log_groups_state
5031 .log_groups
5032 .items
5033 .push(rusticity_core::LogGroup {
5034 name: format!("/aws/lambda/function{}", i),
5035 creation_time: None,
5036 stored_bytes: Some(1024),
5037 retention_days: None,
5038 log_class: None,
5039 arn: None,
5040 });
5041 }
5042
5043 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5045
5046 app.log_groups_state.log_groups.selected = 99;
5048 app.log_groups_state.log_groups.scroll_offset = 50; app.handle_action(Action::PrevItem);
5052 assert_eq!(app.log_groups_state.log_groups.selected, 98);
5053 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); app.handle_action(Action::PrevItem);
5057 assert_eq!(app.log_groups_state.log_groups.selected, 97);
5058 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 50); }
5060
5061 #[test]
5062 fn test_table_up_from_last_visible_row() {
5063 let mut app = test_app();
5064 app.current_service = Service::CloudWatchLogGroups;
5065 app.service_selected = true;
5066 app.view_mode = ViewMode::List;
5067 app.mode = Mode::Normal;
5068
5069 for i in 0..100 {
5071 app.log_groups_state
5072 .log_groups
5073 .items
5074 .push(rusticity_core::LogGroup {
5075 name: format!("/aws/lambda/function{}", i),
5076 creation_time: None,
5077 stored_bytes: Some(1024),
5078 retention_days: None,
5079 log_class: None,
5080 arn: None,
5081 });
5082 }
5083
5084 app.log_groups_state.log_groups.page_size = crate::common::PageSize::Fifty;
5086
5087 app.log_groups_state.log_groups.selected = 49;
5089 app.log_groups_state.log_groups.scroll_offset = 0;
5090 app.handle_action(Action::NextItem);
5091
5092 assert_eq!(app.log_groups_state.log_groups.selected, 50);
5094 assert_eq!(app.log_groups_state.log_groups.scroll_offset, 1);
5095
5096 app.handle_action(Action::PrevItem);
5099 assert_eq!(
5100 app.log_groups_state.log_groups.selected, 49,
5101 "Selection should move to 49"
5102 );
5103 assert_eq!(
5104 app.log_groups_state.log_groups.scroll_offset, 1,
5105 "Should NOT scroll up"
5106 );
5107 }
5108
5109 #[test]
5110 fn test_cloudformation_up_from_last_visible_row() {
5111 let mut app = test_app();
5112 app.current_service = Service::CloudFormationStacks;
5113 app.service_selected = true;
5114 app.mode = Mode::Normal;
5115
5116 for i in 0..100 {
5118 app.cfn_state.table.items.push(crate::cfn::Stack {
5119 name: format!("Stack{}", i),
5120 stack_id: format!("id{}", i),
5121 status: "CREATE_COMPLETE".to_string(),
5122 created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5123 updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5124 deleted_time: String::new(),
5125 description: "Test".to_string(),
5126 drift_status: "NOT_CHECKED".to_string(),
5127 last_drift_check_time: "-".to_string(),
5128 status_reason: String::new(),
5129 detailed_status: "CREATE_COMPLETE".to_string(),
5130 root_stack: String::new(),
5131 parent_stack: String::new(),
5132 termination_protection: false,
5133 iam_role: String::new(),
5134 tags: Vec::new(),
5135 stack_policy: String::new(),
5136 rollback_monitoring_time: String::new(),
5137 rollback_alarms: Vec::new(),
5138 notification_arns: Vec::new(),
5139 });
5140 }
5141
5142 app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
5144
5145 app.cfn_state.table.selected = 49;
5147 app.cfn_state.table.scroll_offset = 0;
5148 app.handle_action(Action::NextItem);
5149
5150 assert_eq!(app.cfn_state.table.selected, 50);
5152 assert_eq!(app.cfn_state.table.scroll_offset, 1);
5153
5154 app.handle_action(Action::PrevItem);
5156 assert_eq!(
5157 app.cfn_state.table.selected, 49,
5158 "Selection should move to 49"
5159 );
5160 assert_eq!(
5161 app.cfn_state.table.scroll_offset, 1,
5162 "Should NOT scroll up - this is the bug!"
5163 );
5164 }
5165
5166 #[test]
5167 fn test_cloudformation_up_from_actual_last_row() {
5168 let mut app = test_app();
5169 app.current_service = Service::CloudFormationStacks;
5170 app.service_selected = true;
5171 app.mode = Mode::Normal;
5172
5173 for i in 0..88 {
5175 app.cfn_state.table.items.push(crate::cfn::Stack {
5176 name: format!("Stack{}", i),
5177 stack_id: format!("id{}", i),
5178 status: "CREATE_COMPLETE".to_string(),
5179 created_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5180 updated_time: "2024-01-01 00:00:00 (UTC)".to_string(),
5181 deleted_time: String::new(),
5182 description: "Test".to_string(),
5183 drift_status: "NOT_CHECKED".to_string(),
5184 last_drift_check_time: "-".to_string(),
5185 status_reason: String::new(),
5186 detailed_status: "CREATE_COMPLETE".to_string(),
5187 root_stack: String::new(),
5188 parent_stack: String::new(),
5189 termination_protection: false,
5190 iam_role: String::new(),
5191 tags: Vec::new(),
5192 stack_policy: String::new(),
5193 rollback_monitoring_time: String::new(),
5194 rollback_alarms: Vec::new(),
5195 notification_arns: Vec::new(),
5196 });
5197 }
5198
5199 app.cfn_state.table.page_size = crate::common::PageSize::Fifty;
5201
5202 app.cfn_state.table.selected = 87;
5205 app.cfn_state.table.scroll_offset = 38; app.handle_action(Action::PrevItem);
5209 assert_eq!(
5210 app.cfn_state.table.selected, 86,
5211 "Selection should move to 86"
5212 );
5213 assert_eq!(
5214 app.cfn_state.table.scroll_offset, 38,
5215 "Should NOT scroll - scroll_offset should stay at 38"
5216 );
5217 }
5218
5219 #[test]
5220 fn test_iam_users_default_columns() {
5221 let app = test_app();
5222 assert_eq!(app.iam_user_visible_column_ids.len(), 11);
5223 assert!(app
5224 .iam_user_visible_column_ids
5225 .contains(&"column.iam.user.user_name"));
5226 assert!(app
5227 .iam_user_visible_column_ids
5228 .contains(&"column.iam.user.path"));
5229 assert!(app
5230 .iam_user_visible_column_ids
5231 .contains(&"column.iam.user.arn"));
5232 }
5233
5234 #[test]
5235 fn test_iam_users_all_columns() {
5236 let app = test_app();
5237 assert_eq!(app.iam_user_column_ids.len(), 14);
5238 assert!(app
5239 .iam_user_column_ids
5240 .contains(&"column.iam.user.creation_time"));
5241 assert!(app
5242 .iam_user_column_ids
5243 .contains(&"column.iam.user.console_access"));
5244 assert!(app
5245 .iam_user_column_ids
5246 .contains(&"column.iam.user.signing_certs"));
5247 }
5248
5249 #[test]
5250 fn test_iam_users_filter() {
5251 let mut app = test_app();
5252 app.current_service = Service::IamUsers;
5253
5254 app.iam_state.users.items = vec![
5256 crate::iam::IamUser {
5257 user_name: "alice".to_string(),
5258 path: "/".to_string(),
5259 groups: "admins".to_string(),
5260 last_activity: "2024-01-01".to_string(),
5261 mfa: "Enabled".to_string(),
5262 password_age: "30 days".to_string(),
5263 console_last_sign_in: "2024-01-01".to_string(),
5264 access_key_id: "AKIA...".to_string(),
5265 active_key_age: "60 days".to_string(),
5266 access_key_last_used: "2024-01-01".to_string(),
5267 arn: "arn:aws:iam::123456789012:user/alice".to_string(),
5268 creation_time: "2023-01-01".to_string(),
5269 console_access: "Enabled".to_string(),
5270 signing_certs: "0".to_string(),
5271 },
5272 crate::iam::IamUser {
5273 user_name: "bob".to_string(),
5274 path: "/".to_string(),
5275 groups: "developers".to_string(),
5276 last_activity: "2024-01-02".to_string(),
5277 mfa: "Disabled".to_string(),
5278 password_age: "45 days".to_string(),
5279 console_last_sign_in: "2024-01-02".to_string(),
5280 access_key_id: "AKIA...".to_string(),
5281 active_key_age: "90 days".to_string(),
5282 access_key_last_used: "2024-01-02".to_string(),
5283 arn: "arn:aws:iam::123456789012:user/bob".to_string(),
5284 creation_time: "2023-02-01".to_string(),
5285 console_access: "Enabled".to_string(),
5286 signing_certs: "1".to_string(),
5287 },
5288 ];
5289
5290 let filtered = crate::ui::iam::filtered_iam_users(&app);
5292 assert_eq!(filtered.len(), 2);
5293
5294 app.iam_state.users.filter = "alice".to_string();
5296 let filtered = crate::ui::iam::filtered_iam_users(&app);
5297 assert_eq!(filtered.len(), 1);
5298 assert_eq!(filtered[0].user_name, "alice");
5299
5300 app.iam_state.users.filter = "BOB".to_string();
5302 let filtered = crate::ui::iam::filtered_iam_users(&app);
5303 assert_eq!(filtered.len(), 1);
5304 assert_eq!(filtered[0].user_name, "bob");
5305 }
5306
5307 #[test]
5308 fn test_iam_users_pagination() {
5309 let mut app = test_app();
5310 app.current_service = Service::IamUsers;
5311
5312 for i in 0..30 {
5314 app.iam_state.users.items.push(crate::iam::IamUser {
5315 user_name: format!("user{}", i),
5316 path: "/".to_string(),
5317 groups: String::new(),
5318 last_activity: "-".to_string(),
5319 mfa: "Disabled".to_string(),
5320 password_age: "-".to_string(),
5321 console_last_sign_in: "-".to_string(),
5322 access_key_id: "-".to_string(),
5323 active_key_age: "-".to_string(),
5324 access_key_last_used: "-".to_string(),
5325 arn: format!("arn:aws:iam::123456789012:user/user{}", i),
5326 creation_time: "2023-01-01".to_string(),
5327 console_access: "Disabled".to_string(),
5328 signing_certs: "0".to_string(),
5329 });
5330 }
5331
5332 app.iam_state.users.page_size = crate::common::PageSize::TwentyFive;
5334
5335 let filtered = crate::ui::iam::filtered_iam_users(&app);
5336 assert_eq!(filtered.len(), 30);
5337
5338 let page_size = app.iam_state.users.page_size.value();
5340 assert_eq!(page_size, 25);
5341 }
5342
5343 #[test]
5344 fn test_iam_users_expansion() {
5345 let mut app = test_app();
5346 app.current_service = Service::IamUsers;
5347 app.service_selected = true;
5348 app.mode = Mode::Normal;
5349
5350 app.iam_state.users.items = vec![crate::iam::IamUser {
5351 user_name: "testuser".to_string(),
5352 path: "/admin/".to_string(),
5353 groups: "admins,developers".to_string(),
5354 last_activity: "2024-01-01".to_string(),
5355 mfa: "Enabled".to_string(),
5356 password_age: "30 days".to_string(),
5357 console_last_sign_in: "2024-01-01 10:00:00".to_string(),
5358 access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
5359 active_key_age: "60 days".to_string(),
5360 access_key_last_used: "2024-01-01 09:00:00".to_string(),
5361 arn: "arn:aws:iam::123456789012:user/admin/testuser".to_string(),
5362 creation_time: "2023-01-01 00:00:00".to_string(),
5363 console_access: "Enabled".to_string(),
5364 signing_certs: "2".to_string(),
5365 }];
5366
5367 app.handle_action(Action::NextPane);
5369 assert_eq!(app.iam_state.users.expanded_item, Some(0));
5370
5371 app.handle_action(Action::PrevPane);
5373 assert_eq!(app.iam_state.users.expanded_item, None);
5374 }
5375
5376 #[test]
5377 fn test_iam_users_in_service_picker() {
5378 let app = test_app();
5379 assert!(app.service_picker.services.contains(&"IAM > Users"));
5380 }
5381
5382 #[test]
5383 fn test_iam_users_service_selection() {
5384 let mut app = test_app();
5385 app.mode = Mode::ServicePicker;
5386 let filtered = app.filtered_services();
5387 let selected_idx = filtered.iter().position(|&s| s == "IAM > Users").unwrap();
5388 app.service_picker.selected = selected_idx;
5389
5390 app.handle_action(Action::Select);
5391
5392 assert_eq!(app.current_service, Service::IamUsers);
5393 assert!(app.service_selected);
5394 assert_eq!(app.tabs.len(), 1);
5395 assert_eq!(app.tabs[0].service, Service::IamUsers);
5396 assert_eq!(app.tabs[0].title, "IAM > Users");
5397 }
5398
5399 #[test]
5400 fn test_format_duration_seconds() {
5401 assert_eq!(format_duration(1), "1 second");
5402 assert_eq!(format_duration(30), "30 seconds");
5403 }
5404
5405 #[test]
5406 fn test_format_duration_minutes() {
5407 assert_eq!(format_duration(60), "1 minute");
5408 assert_eq!(format_duration(120), "2 minutes");
5409 assert_eq!(format_duration(3600 - 1), "59 minutes");
5410 }
5411
5412 #[test]
5413 fn test_format_duration_hours() {
5414 assert_eq!(format_duration(3600), "1 hour");
5415 assert_eq!(format_duration(7200), "2 hours");
5416 assert_eq!(format_duration(3600 + 1800), "1 hour 30 minutes");
5417 assert_eq!(format_duration(7200 + 60), "2 hours 1 minute");
5418 }
5419
5420 #[test]
5421 fn test_format_duration_days() {
5422 assert_eq!(format_duration(86400), "1 day");
5423 assert_eq!(format_duration(172800), "2 days");
5424 assert_eq!(format_duration(86400 + 3600), "1 day 1 hour");
5425 assert_eq!(format_duration(172800 + 7200), "2 days 2 hours");
5426 }
5427
5428 #[test]
5429 fn test_format_duration_weeks() {
5430 assert_eq!(format_duration(604800), "1 week");
5431 assert_eq!(format_duration(1209600), "2 weeks");
5432 assert_eq!(format_duration(604800 + 86400), "1 week 1 day");
5433 assert_eq!(format_duration(1209600 + 172800), "2 weeks 2 days");
5434 }
5435
5436 #[test]
5437 fn test_format_duration_years() {
5438 assert_eq!(format_duration(31536000), "1 year");
5439 assert_eq!(format_duration(63072000), "2 years");
5440 assert_eq!(format_duration(31536000 + 604800), "1 year 1 week");
5441 assert_eq!(format_duration(63072000 + 1209600), "2 years 2 weeks");
5442 }
5443
5444 #[test]
5445 fn test_tab_style_selected() {
5446 let style = tab_style(true);
5447 assert_eq!(style, highlight());
5448 }
5449
5450 #[test]
5451 fn test_tab_style_not_selected() {
5452 let style = tab_style(false);
5453 assert_eq!(style, Style::default());
5454 }
5455
5456 #[test]
5457 fn test_render_tab_spans_single_tab() {
5458 let tabs = [("Tab1", true)];
5459 let spans = render_tab_spans(&tabs);
5460 assert_eq!(spans.len(), 1);
5461 assert_eq!(spans[0].content, "Tab1");
5462 assert_eq!(spans[0].style, service_tab_style(true));
5463 }
5464
5465 #[test]
5466 fn test_render_tab_spans_multiple_tabs() {
5467 let tabs = [("Tab1", true), ("Tab2", false), ("Tab3", false)];
5468 let spans = render_tab_spans(&tabs);
5469 assert_eq!(spans.len(), 5); assert_eq!(spans[0].content, "Tab1");
5471 assert_eq!(spans[0].style, service_tab_style(true));
5472 assert_eq!(spans[1].content, " ⋮ ");
5473 assert_eq!(spans[2].content, "Tab2");
5474 assert_eq!(spans[2].style, Style::default());
5475 assert_eq!(spans[3].content, " ⋮ ");
5476 assert_eq!(spans[4].content, "Tab3");
5477 assert_eq!(spans[4].style, Style::default());
5478 }
5479
5480 #[test]
5481 fn test_render_tab_spans_no_separator_for_first() {
5482 let tabs = [("First", false), ("Second", true)];
5483 let spans = render_tab_spans(&tabs);
5484 assert_eq!(spans.len(), 3); assert_eq!(spans[0].content, "First");
5486 assert_eq!(spans[1].content, " ⋮ ");
5487 assert_eq!(spans[2].content, "Second");
5488 assert_eq!(spans[2].style, service_tab_style(true));
5489 }
5490}