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