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