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