Skip to main content

rusticity_term/ui/
dynamodb.rs

1use crate::app::App;
2use crate::common::{
3    format_bytes, format_iso_timestamp, render_pagination_text, render_partial_pagination,
4    CyclicEnum, InputFocus, SortDirection, UTC_TIMESTAMP_WIDTH,
5};
6use crate::dynamodb::items::{self, ItemRequestContext};
7use crate::dynamodb::table;
8use crate::table::{TableState, TableWindow};
9use crate::tags::{self, TagSort, TagsConfig};
10use crate::ui::filter::{render_simple_filter, SimpleFilterConfig};
11use crate::ui::format_title;
12use crate::ui::render_tabs;
13use crate::ui::table::{expanded_from_columns, render_table, Column, TableConfig};
14use crate::ui::Summary;
15use ratatui::prelude::*;
16use ratatui::widgets::Paragraph;
17use rusticity_core::dynamodb::{
18    DdbBackup, DdbIndex, DdbItem, DdbItemCursor, DdbMonitoringData, DdbReplica, DdbTable,
19    DdbTableSettings,
20};
21
22pub const FILTER_CONTROLS: [InputFocus; 2] = [InputFocus::Filter, InputFocus::Pagination];
23
24/// Dropdowns on the Items tab filter row.
25pub const ITEM_OPERATION: InputFocus = InputFocus::Dropdown("DdbItemOperation");
26pub const ITEM_INDEX: InputFocus = InputFocus::Dropdown("DdbItemIndex");
27pub const ITEM_PROJECTION: InputFocus = InputFocus::Dropdown("DdbItemProjection");
28
29/// Focus order on the Items tab: the three dropdowns, then the text input and
30/// pagination.
31pub const ITEMS_FILTER_CONTROLS: [InputFocus; 5] = [
32    ITEM_OPERATION,
33    ITEM_INDEX,
34    ITEM_PROJECTION,
35    InputFocus::Filter,
36    InputFocus::Pagination,
37];
38
39/// Maximum evaluated items per request; AWS may stop earlier at its response-size limit.
40pub const ITEM_LIMIT: i32 = 100;
41
42/// Items tab operation. Scan is the default, matching the console.
43#[derive(Debug, Clone, Copy, PartialEq, Default)]
44pub enum ItemOperation {
45    #[default]
46    Scan,
47    Query,
48}
49
50impl CyclicEnum for ItemOperation {
51    const ALL: &'static [Self] = &[Self::Scan, Self::Query];
52}
53
54impl ItemOperation {
55    pub fn name(&self) -> &'static str {
56        match self {
57            ItemOperation::Scan => "Scan",
58            ItemOperation::Query => "Query",
59        }
60    }
61}
62
63/// Which attributes a Scan or Query returns.
64#[derive(Debug, Clone, Copy, PartialEq, Default)]
65pub enum ItemProjection {
66    #[default]
67    AllAttributes,
68    KeysOnly,
69}
70
71impl CyclicEnum for ItemProjection {
72    const ALL: &'static [Self] = &[Self::AllAttributes, Self::KeysOnly];
73}
74
75impl ItemProjection {
76    pub fn name(&self) -> &'static str {
77        match self {
78            ItemProjection::AllAttributes => "All attributes",
79            ItemProjection::KeysOnly => "Keys only",
80        }
81    }
82}
83
84/// Fixed column names for the Indexes detail table.
85pub const INDEXES_COLUMNS: [&str; 10] = [
86    "Name",
87    "Type",
88    "Status",
89    "Partition key",
90    "Sort key",
91    "Attributes",
92    "Read capacity mode",
93    "Write capacity mode",
94    "Size",
95    "Item count",
96];
97/// Fixed column names for the Global tables (replicas) detail table.
98pub const REPLICAS_COLUMNS: [&str; 5] = ["Region", "Status", "Table class", "KMS key", "Indexes"];
99/// Fixed column names for the Backups detail table.
100pub const BACKUPS_COLUMNS: [&str; 6] = [
101    "Backup name",
102    "Status",
103    "Type",
104    "Size",
105    "Creation time",
106    "Expiry time",
107];
108/// Fixed column names for the Tags detail table.
109pub const TAGS_COLUMNS: [&str; 2] = ["Key", "Value"];
110
111/// Fixed column names for the active detail tab, if it is a fixed-column table.
112pub fn detail_fixed_columns(detail_tab: DetailTab) -> Option<&'static [&'static str]> {
113    match detail_tab {
114        DetailTab::Indexes => Some(&INDEXES_COLUMNS),
115        DetailTab::GlobalTables => Some(&REPLICAS_COLUMNS),
116        DetailTab::Backups => Some(&BACKUPS_COLUMNS),
117        DetailTab::Tags => Some(&TAGS_COLUMNS),
118        _ => None,
119    }
120}
121
122/// Detail-view ribbon tabs (mirrors the DynamoDB console).
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub enum DetailTab {
125    Items,
126    Settings,
127    Indexes,
128    Monitor,
129    GlobalTables,
130    Backups,
131    ExportsStreams,
132    AdditionalSettings,
133    Tags,
134}
135
136impl CyclicEnum for DetailTab {
137    const ALL: &'static [Self] = &[
138        Self::Items,
139        Self::Settings,
140        Self::Indexes,
141        Self::Monitor,
142        Self::GlobalTables,
143        Self::Backups,
144        Self::ExportsStreams,
145        Self::AdditionalSettings,
146        Self::Tags,
147    ];
148}
149
150impl DetailTab {
151    pub fn name(&self) -> &'static str {
152        match self {
153            DetailTab::Items => "Items",
154            DetailTab::Settings => "Settings",
155            DetailTab::Indexes => "Indexes",
156            DetailTab::Monitor => "Monitor",
157            DetailTab::GlobalTables => "Global tables",
158            DetailTab::Backups => "Backups",
159            DetailTab::ExportsStreams => "Exports and streams",
160            DetailTab::AdditionalSettings => "Additional settings",
161            DetailTab::Tags => "Tags",
162        }
163    }
164
165    /// The matching dynamodbv2 console tab id (used for the console URL).
166    pub fn console_tab(&self) -> &'static str {
167        match self {
168            DetailTab::Items => "item-explorer",
169            DetailTab::Settings => "overview",
170            DetailTab::Indexes => "indexes",
171            DetailTab::Monitor => "monitoring",
172            DetailTab::GlobalTables => "globaltables",
173            DetailTab::Backups => "backups",
174            DetailTab::ExportsStreams => "exportsandstreams",
175            DetailTab::AdditionalSettings => "additionalsettings",
176            DetailTab::Tags => "tags",
177        }
178    }
179}
180
181pub struct State {
182    pub tables: TableState<DdbTable>,
183    pub input_focus: InputFocus,
184    pub current_table: Option<String>,
185    pub detail_tab: DetailTab,
186    pub items: TableState<DdbItem>,
187    pub items_loading: bool,
188    pub items_cursor: Option<DdbItemCursor>,
189    pub items_context: Option<ItemRequestContext>,
190    /// Whether the scheduled/failed request continues the current result set.
191    pub items_append: bool,
192    pub items_failed: bool,
193    /// Items tab text input: a client-side filter for Scan, the partition-key
194    /// value for Query. Kept apart from the table-list filter.
195    pub item_filter: String,
196    pub item_operation: ItemOperation,
197    /// 0 = the table itself, 1.. = secondary indexes in ribbon order.
198    pub item_index: usize,
199    pub item_projection: ItemProjection,
200    /// Attribute columns hidden in Preferences. Item columns are discovered from
201    /// the data, so they are tracked by name and default to all visible.
202    pub item_hidden_columns: Vec<String>,
203    /// Partition-key value the last Query ran with, so it only re-runs on change.
204    pub executed_query: String,
205    pub indexes_table: TableState<DdbIndex>,
206    pub replicas_table: TableState<DdbReplica>,
207    pub backups: TableState<DdbBackup>,
208    pub backups_loading: bool,
209    pub settings: DdbTableSettings,
210    pub settings_loading: bool,
211    pub tags_table: TableState<(String, String)>,
212    pub tags_loading: bool,
213    /// Resource-based policy documents (Additional settings tab).
214    pub table_policy: Option<String>,
215    pub stream_policy: Option<String>,
216    pub permissions_loading: bool,
217    /// Monitor tab.
218    pub metrics: DdbMonitoringData,
219    pub metrics_loading: bool,
220    pub monitoring_scroll: usize,
221}
222
223/// Number of charts on the Monitor tab; the last scrollable index is one less.
224pub const MONITORING_CHARTS: usize = 4;
225
226impl crate::ui::monitoring::MonitoringState for State {
227    fn is_metrics_loading(&self) -> bool {
228        self.metrics_loading
229    }
230
231    fn set_metrics_loading(&mut self, loading: bool) {
232        self.metrics_loading = loading;
233    }
234
235    fn monitoring_scroll(&self) -> usize {
236        self.monitoring_scroll
237    }
238
239    fn set_monitoring_scroll(&mut self, scroll: usize) {
240        self.monitoring_scroll = scroll;
241    }
242
243    fn clear_metrics(&mut self) {
244        self.metrics = DdbMonitoringData::default();
245    }
246}
247
248impl Default for State {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254impl State {
255    pub fn new() -> Self {
256        Self {
257            tables: TableState::new(),
258            input_focus: InputFocus::Filter,
259            current_table: None,
260            detail_tab: DetailTab::Items,
261            items: TableState::new(),
262            items_loading: false,
263            items_cursor: None,
264            items_context: None,
265            items_append: false,
266            items_failed: false,
267            item_filter: String::new(),
268            item_operation: ItemOperation::default(),
269            item_index: 0,
270            item_projection: ItemProjection::default(),
271            item_hidden_columns: Vec::new(),
272            executed_query: String::new(),
273            indexes_table: TableState::new(),
274            replicas_table: TableState::new(),
275            backups: TableState::new(),
276            backups_loading: false,
277            settings: DdbTableSettings::default(),
278            settings_loading: false,
279            tags_table: TableState::new(),
280            tags_loading: false,
281            table_policy: None,
282            stream_policy: None,
283            permissions_loading: false,
284            metrics: DdbMonitoringData::default(),
285            metrics_loading: false,
286            monitoring_scroll: 0,
287        }
288    }
289}
290
291pub fn filtered_dynamodb_tables(app: &App) -> Vec<&DdbTable> {
292    let filter = app.dynamodb_state.tables.filter.to_lowercase();
293    app.dynamodb_state
294        .tables
295        .items
296        .iter()
297        .filter(|t| {
298            filter.is_empty()
299                || t.name.to_lowercase().contains(&filter)
300                || t.status.to_lowercase().contains(&filter)
301                || t.partition_key.to_lowercase().contains(&filter)
302        })
303        .collect()
304}
305
306/// The table currently open in the detail view.
307pub fn current_table(app: &App) -> Option<&DdbTable> {
308    app.dynamodb_state
309        .current_table
310        .as_deref()
311        .and_then(|name| {
312            app.dynamodb_state
313                .tables
314                .items
315                .iter()
316                .find(|t| t.name == name)
317        })
318}
319
320/// True for a queued Items request or changed server request context. Local
321/// Scan filtering and navigation do not fetch additional pages.
322pub fn needs_items_load(app: &App) -> bool {
323    items::needs_load(app)
324}
325
326pub fn render_tables(frame: &mut Frame, app: &App, area: Rect) {
327    if app.dynamodb_state.current_table.is_some() {
328        render_table_detail(frame, app, area);
329    } else {
330        render_tables_list(frame, app, area);
331    }
332}
333
334// ── Formatting helpers ────────────────────────────────────────────────────────
335
336/// Green for "Enabled", yellow for "Disabled".
337pub fn enabled_disabled_color(value: &str) -> Color {
338    match value.to_uppercase().as_str() {
339        "ENABLED" => Color::Green,
340        "DISABLED" => Color::Yellow,
341        _ => Color::White,
342    }
343}
344
345/// "On-demand" or "Provisioned (5 RCU)"-style capacity description.
346pub fn format_capacity(mode: &str, units: i64, unit_label: &str) -> String {
347    if mode == "Provisioned" && units > 0 {
348        format!("{} ({} {})", mode, units, unit_label)
349    } else {
350        mode.to_string()
351    }
352}
353
354/// Show "-" for empty values so panes never render a dangling label.
355fn or_dash(value: &str) -> String {
356    if value.is_empty() {
357        "-".to_string()
358    } else {
359        value.to_string()
360    }
361}
362
363fn build_summary_lines(t: &DdbTable) -> Vec<Line<'static>> {
364    use crate::ui::{labeled_field, labeled_field_colored};
365
366    let (status_text, status_color) = table::format_status(&t.status);
367
368    let mut lines: Vec<Line> = vec![
369        labeled_field("Table name", t.name.clone()),
370        labeled_field_colored("Status", status_text.to_string(), status_color),
371        labeled_field("Partition key", or_dash(&t.partition_key)),
372        labeled_field("Sort key", or_dash(&t.sort_key)),
373        labeled_field(
374            "Read capacity mode",
375            format_capacity(&t.read_capacity_mode, t.provisioned_read_units, "RCU"),
376        ),
377        labeled_field(
378            "Write capacity mode",
379            format_capacity(&t.write_capacity_mode, t.provisioned_write_units, "WCU"),
380        ),
381        labeled_field("Table class", t.table_class.clone()),
382        labeled_field("Total size", format_bytes(t.total_size_bytes)),
383        labeled_field("Item count", t.item_count.to_string()),
384        labeled_field("Indexes", t.index_count().to_string()),
385        labeled_field_colored(
386            "Deletion protection",
387            t.deletion_protection.clone(),
388            enabled_disabled_color(&t.deletion_protection),
389        ),
390    ];
391    if !t.creation_time.is_empty() {
392        lines.push(labeled_field(
393            "Creation time",
394            format_iso_timestamp(&t.creation_time),
395        ));
396    }
397    lines
398}
399
400pub fn render_table_detail(frame: &mut Frame, app: &App, area: Rect) {
401    let tabs: Vec<(&str, DetailTab)> = DetailTab::ALL.iter().map(|t| (t.name(), *t)).collect();
402
403    let Some(t) = current_table(app) else {
404        let chunks = Layout::default()
405            .direction(Direction::Vertical)
406            .constraints([Constraint::Min(0), Constraint::Length(1)])
407            .split(area);
408        render_tabs(frame, chunks[1], &tabs, &app.dynamodb_state.detail_tab);
409        return;
410    };
411
412    // The Items tab is a full-height explorer: ribbon on top, no Summary pane.
413    if app.dynamodb_state.detail_tab == DetailTab::Items {
414        let chunks = Layout::default()
415            .direction(Direction::Vertical)
416            .constraints([Constraint::Length(1), Constraint::Min(0)])
417            .split(area);
418        render_tabs(frame, chunks[0], &tabs, &app.dynamodb_state.detail_tab);
419        render_items_tab(frame, app, t, chunks[1]);
420        return;
421    }
422
423    let summary = Summary::new("Summary", build_summary_lines(t));
424    let summary_height = summary
425        .height(area.width)
426        .min(area.height.saturating_sub(2));
427
428    let chunks = Layout::default()
429        .direction(Direction::Vertical)
430        .constraints([
431            Constraint::Length(summary_height),
432            Constraint::Length(1),
433            Constraint::Min(0),
434        ])
435        .split(area);
436
437    summary.render(frame, chunks[0]);
438
439    render_tabs(frame, chunks[1], &tabs, &app.dynamodb_state.detail_tab);
440
441    match app.dynamodb_state.detail_tab {
442        // Handled above — it renders without the Summary pane.
443        DetailTab::Items => {}
444        DetailTab::Settings => render_settings(frame, app, t, chunks[2]),
445        DetailTab::Indexes => render_indexes_tab(frame, app, t, chunks[2]),
446        DetailTab::Monitor => render_monitor_tab(frame, app, t, chunks[2]),
447        DetailTab::GlobalTables => render_global_tables_tab(frame, app, t, chunks[2]),
448        DetailTab::Backups => render_backups_tab(frame, app, chunks[2]),
449        DetailTab::ExportsStreams => render_exports_streams(frame, t, chunks[2]),
450        DetailTab::AdditionalSettings => render_additional_settings(frame, app, chunks[2]),
451        DetailTab::Tags => render_tags_tab(frame, app, chunks[2]),
452    }
453}
454
455/// A simple label/width/extractor column adapter for fixed detail tables.
456struct FieldCol<T: 'static> {
457    label: &'static str,
458    width: u16,
459    get: fn(&T) -> String,
460}
461
462impl<T> Column<T> for FieldCol<T> {
463    fn name(&self) -> &str {
464        self.label
465    }
466    fn width(&self) -> u16 {
467        self.width
468    }
469    fn render(&self, item: &T) -> (String, Style) {
470        ((self.get)(item), Style::default())
471    }
472}
473
474fn render_settings(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
475    use crate::ui::{labeled_field, labeled_field_colored, render_fields_with_dynamic_columns};
476
477    let s = &app.dynamodb_state.settings;
478    let ttl_status = if app.dynamodb_state.settings_loading {
479        "Loading...".to_string()
480    } else {
481        or_dash(&s.ttl_status)
482    };
483
484    let general: Vec<Line> = vec![
485        labeled_field("ARN", or_dash(&t.table_arn)),
486        labeled_field("Table ID", or_dash(&t.table_id)),
487        labeled_field("Encryption type", or_dash(&t.encryption_type)),
488        labeled_field("KMS key ARN", or_dash(&t.kms_key_arn)),
489        labeled_field_colored(
490            "Time to Live (TTL)",
491            ttl_status.clone(),
492            enabled_disabled_color(&ttl_status),
493        ),
494        labeled_field("TTL attribute", or_dash(&s.ttl_attribute)),
495        labeled_field_colored(
496            "Deletion protection",
497            t.deletion_protection.clone(),
498            enabled_disabled_color(&t.deletion_protection),
499        ),
500    ];
501    let capacity: Vec<Line> = vec![
502        labeled_field(
503            "Read capacity",
504            format_capacity(&t.read_capacity_mode, t.provisioned_read_units, "RCU"),
505        ),
506        labeled_field(
507            "Write capacity",
508            format_capacity(&t.write_capacity_mode, t.provisioned_write_units, "WCU"),
509        ),
510        labeled_field("Table class", t.table_class.clone()),
511        labeled_field("Replicas", t.replicas.len().to_string()),
512    ];
513
514    let summary = Summary::new("General information", general);
515    let h1 = summary.height(area.width).min(area.height);
516    let chunks = Layout::default()
517        .direction(Direction::Vertical)
518        .constraints([Constraint::Length(h1), Constraint::Min(0)])
519        .split(area);
520
521    summary.render(frame, chunks[0]);
522
523    let block2 = crate::ui::titled_block(" Capacity ");
524    let inner2 = block2.inner(chunks[1]);
525    frame.render_widget(block2, chunks[1]);
526    render_fields_with_dynamic_columns(frame, inner2, capacity);
527}
528
529fn render_exports_streams(frame: &mut Frame, t: &DdbTable, area: Rect) {
530    use crate::ui::{labeled_field, labeled_field_colored, render_fields_with_dynamic_columns};
531
532    let lines: Vec<Line> = vec![
533        labeled_field_colored(
534            "DynamoDB stream",
535            t.stream_enabled.clone(),
536            enabled_disabled_color(&t.stream_enabled),
537        ),
538        labeled_field("View type", or_dash(&t.stream_view_type)),
539        labeled_field("Latest stream ARN", or_dash(&t.latest_stream_arn)),
540    ];
541
542    let block = crate::ui::titled_block(" DynamoDB stream details ");
543    let inner = block.inner(area);
544    frame.render_widget(block, area);
545    render_fields_with_dynamic_columns(frame, inner, lines);
546}
547
548fn render_additional_settings(frame: &mut Frame, app: &App, area: Rect) {
549    let chunks = Layout::default()
550        .direction(Direction::Vertical)
551        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
552        .split(area);
553
554    render_policy_pane(
555        frame,
556        chunks[0],
557        " Resource-based policy for table ",
558        app.dynamodb_state.table_policy.as_deref(),
559        "No table resource-based policy to display",
560        app.dynamodb_state.permissions_loading,
561    );
562    render_policy_pane(
563        frame,
564        chunks[1],
565        " Resource-based policy for active stream ",
566        app.dynamodb_state.stream_policy.as_deref(),
567        "No stream resource-based policy to display",
568        app.dynamodb_state.permissions_loading,
569    );
570}
571
572/// A JSON policy pane, or a centered placeholder when there is no policy.
573fn render_policy_pane(
574    frame: &mut Frame,
575    area: Rect,
576    title: &str,
577    policy: Option<&str>,
578    empty_message: &str,
579    loading: bool,
580) {
581    let block = crate::ui::titled_block(title.to_string());
582    let inner = block.inner(area);
583    frame.render_widget(block, area);
584
585    if loading {
586        let p = Paragraph::new("Loading policy...")
587            .style(Style::default().fg(Color::DarkGray))
588            .alignment(Alignment::Center);
589        frame.render_widget(p, inner);
590        return;
591    }
592
593    match policy {
594        Some(json) if !json.is_empty() => {
595            frame.render_widget(Paragraph::new(json.to_string()), inner);
596        }
597        _ => {
598            let p = Paragraph::new(empty_message.to_string())
599                .style(Style::default().fg(Color::DarkGray))
600                .alignment(Alignment::Center);
601            frame.render_widget(p, inner);
602        }
603    }
604}
605
606fn render_indexes_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
607    let rows: Vec<&DdbIndex> = t
608        .global_secondary_indexes
609        .iter()
610        .chain(t.local_secondary_indexes.iter())
611        .collect();
612
613    let columns: Vec<Box<dyn Column<DdbIndex>>> = vec![
614        Box::new(FieldCol {
615            label: "Name",
616            width: 26,
617            get: |i: &DdbIndex| i.name.clone(),
618        }),
619        Box::new(FieldCol {
620            label: "Type",
621            width: 8,
622            get: |i: &DdbIndex| i.index_type.clone(),
623        }),
624        Box::new(FieldCol {
625            label: "Status",
626            width: 12,
627            get: |i: &DdbIndex| i.status.clone(),
628        }),
629        Box::new(FieldCol {
630            label: "Partition key",
631            width: 20,
632            get: |i: &DdbIndex| or_dash(&i.partition_key),
633        }),
634        Box::new(FieldCol {
635            label: "Sort key",
636            width: 20,
637            get: |i: &DdbIndex| or_dash(&i.sort_key),
638        }),
639        Box::new(FieldCol {
640            label: "Attributes",
641            width: 20,
642            get: |i: &DdbIndex| i.projection.clone(),
643        }),
644        Box::new(FieldCol {
645            label: "Read capacity mode",
646            width: 18,
647            get: |i: &DdbIndex| i.read_capacity_mode.clone(),
648        }),
649        Box::new(FieldCol {
650            label: "Write capacity mode",
651            width: 19,
652            get: |i: &DdbIndex| i.write_capacity_mode.clone(),
653        }),
654        Box::new(FieldCol {
655            label: "Size",
656            width: 12,
657            get: |i: &DdbIndex| format_bytes(i.size_bytes),
658        }),
659        Box::new(FieldCol {
660            label: "Item count",
661            width: 11,
662            get: |i: &DdbIndex| i.item_count.to_string(),
663        }),
664    ];
665
666    let selected = app
667        .dynamodb_state
668        .indexes_table
669        .selected
670        .min(rows.len().saturating_sub(1));
671
672    let config = TableConfig {
673        items: rows.clone(),
674        selected_index: selected,
675        expanded_index: app.dynamodb_state.indexes_table.expanded_item,
676        columns: &columns,
677        sort_column: "Name",
678        sort_direction: SortDirection::Asc,
679        title: format_title(&format!("Indexes ({})", rows.len())),
680        area,
681        get_expanded_content: Some(Box::new(|i: &DdbIndex| expanded_from_columns(&columns, i))),
682        is_active: app.mode != crate::keymap::Mode::FilterInput,
683    };
684    render_table(frame, config);
685}
686
687fn render_global_tables_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
688    use crate::ui::labeled_field_colored;
689
690    // Settings replication pane. The API does not expose per-setting overrides,
691    // so this reflects whether the table has replicas at all.
692    let status = if t.replicas.is_empty() {
693        "Disabled"
694    } else {
695        "Enabled"
696    };
697    let repl_lines: Vec<Line> = vec![labeled_field_colored(
698        "Settings replication status",
699        status.to_string(),
700        enabled_disabled_color(status),
701    )];
702    let summary = Summary::new("Settings replication", repl_lines);
703    let repl_height = summary.height(area.width).min(area.height);
704    let chunks = Layout::default()
705        .direction(Direction::Vertical)
706        .constraints([Constraint::Length(repl_height), Constraint::Min(0)])
707        .split(area);
708
709    summary.render(frame, chunks[0]);
710
711    let area = chunks[1];
712    let rows: Vec<&DdbReplica> = t.replicas.iter().collect();
713
714    let columns: Vec<Box<dyn Column<DdbReplica>>> = vec![
715        Box::new(FieldCol {
716            label: "Region",
717            width: 16,
718            get: |r: &DdbReplica| r.region.clone(),
719        }),
720        Box::new(FieldCol {
721            label: "Status",
722            width: 14,
723            get: |r: &DdbReplica| r.status.clone(),
724        }),
725        Box::new(FieldCol {
726            label: "Table class",
727            width: 14,
728            get: |r: &DdbReplica| or_dash(&r.table_class),
729        }),
730        Box::new(FieldCol {
731            label: "KMS key",
732            width: 30,
733            get: |r: &DdbReplica| or_dash(&r.kms_key_id),
734        }),
735        Box::new(FieldCol {
736            label: "Indexes",
737            width: 8,
738            get: |r: &DdbReplica| r.index_count.to_string(),
739        }),
740    ];
741
742    let selected = app
743        .dynamodb_state
744        .replicas_table
745        .selected
746        .min(rows.len().saturating_sub(1));
747
748    let config = TableConfig {
749        items: rows.clone(),
750        selected_index: selected,
751        expanded_index: app.dynamodb_state.replicas_table.expanded_item,
752        columns: &columns,
753        sort_column: "Region",
754        sort_direction: SortDirection::Asc,
755        title: format_title(&format!("Replicas ({})", rows.len())),
756        area,
757        get_expanded_content: Some(Box::new(|r: &DdbReplica| {
758            expanded_from_columns(&columns, r)
759        })),
760        is_active: app.mode != crate::keymap::Mode::FilterInput,
761    };
762    render_table(frame, config);
763}
764
765fn render_backups_tab(frame: &mut Frame, app: &App, area: Rect) {
766    use crate::ui::{labeled_field, labeled_field_colored};
767
768    // Point-in-time recovery pane.
769    let s = &app.dynamodb_state.settings;
770    let pitr_status = if app.dynamodb_state.settings_loading {
771        "Loading...".to_string()
772    } else if s.pitr_status.is_empty() {
773        "Off".to_string()
774    } else if s.pitr_status == "Enabled" {
775        "On".to_string()
776    } else {
777        "Off".to_string()
778    };
779    let mut pitr_lines: Vec<Line> = vec![labeled_field_colored(
780        "Status",
781        pitr_status.clone(),
782        if pitr_status == "On" {
783            Color::Green
784        } else {
785            Color::Yellow
786        },
787    )];
788    if s.recovery_period_days > 0 {
789        pitr_lines.push(labeled_field(
790            "Backup recovery period",
791            format!("{} days", s.recovery_period_days),
792        ));
793    }
794    if !s.earliest_restorable_time.is_empty() {
795        pitr_lines.push(labeled_field(
796            "Earliest restore point",
797            format_iso_timestamp(&s.earliest_restorable_time),
798        ));
799    }
800    if !s.latest_restorable_time.is_empty() {
801        pitr_lines.push(labeled_field(
802            "Latest restore point",
803            format_iso_timestamp(&s.latest_restorable_time),
804        ));
805    }
806
807    let summary = Summary::new("Point-in-time recovery (PITR)", pitr_lines);
808    let pitr_height = summary.height(area.width).min(area.height);
809    let chunks = Layout::default()
810        .direction(Direction::Vertical)
811        .constraints([Constraint::Length(pitr_height), Constraint::Min(0)])
812        .split(area);
813
814    summary.render(frame, chunks[0]);
815
816    let area = chunks[1];
817
818    if app.dynamodb_state.backups_loading {
819        let block = crate::ui::titled_block(" Backups ");
820        let inner = block.inner(area);
821        frame.render_widget(block, area);
822        let p = Paragraph::new("Loading backups...")
823            .style(Style::default().fg(Color::DarkGray))
824            .alignment(Alignment::Center);
825        frame.render_widget(p, inner);
826        return;
827    }
828
829    let rows: Vec<&DdbBackup> = app.dynamodb_state.backups.items.iter().collect();
830
831    let columns: Vec<Box<dyn Column<DdbBackup>>> = vec![
832        Box::new(FieldCol {
833            label: "Backup name",
834            width: 30,
835            get: |b: &DdbBackup| b.name.clone(),
836        }),
837        Box::new(FieldCol {
838            label: "Status",
839            width: 12,
840            get: |b: &DdbBackup| b.status.clone(),
841        }),
842        Box::new(FieldCol {
843            label: "Type",
844            width: 12,
845            get: |b: &DdbBackup| b.backup_type.clone(),
846        }),
847        Box::new(FieldCol {
848            label: "Size",
849            width: 12,
850            get: |b: &DdbBackup| format_bytes(b.size_bytes),
851        }),
852        Box::new(FieldCol {
853            label: "Creation time",
854            width: UTC_TIMESTAMP_WIDTH,
855            get: |b: &DdbBackup| format_iso_timestamp(&b.creation_time),
856        }),
857        Box::new(FieldCol {
858            label: "Expiry time",
859            width: UTC_TIMESTAMP_WIDTH,
860            get: |b: &DdbBackup| format_iso_timestamp(&b.expiry_time),
861        }),
862    ];
863
864    let selected = app
865        .dynamodb_state
866        .backups
867        .selected
868        .min(rows.len().saturating_sub(1));
869
870    let config = TableConfig {
871        items: rows,
872        selected_index: selected,
873        expanded_index: app.dynamodb_state.backups.expanded_item,
874        columns: &columns,
875        sort_column: "Creation time",
876        sort_direction: SortDirection::Desc,
877        title: format_title(&format!(
878            "Backups ({})",
879            app.dynamodb_state.backups.items.len()
880        )),
881        area,
882        get_expanded_content: Some(Box::new(|b: &DdbBackup| expanded_from_columns(&columns, b))),
883        is_active: app.mode != crate::keymap::Mode::FilterInput,
884    };
885    render_table(frame, config);
886}
887
888/// Monitor tab — CloudWatch charts for capacity, throttling and latency.
889fn render_monitor_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
890    use crate::ui::monitoring::{render_monitoring_tab, MetricChart, MultiDatasetChart};
891
892    if app.dynamodb_state.metrics_loading {
893        let block = crate::ui::titled_block(" Monitor ");
894        let inner = block.inner(area);
895        frame.render_widget(block, area);
896        let p = Paragraph::new("Loading metrics...")
897            .style(Style::default().fg(Color::DarkGray))
898            .alignment(Alignment::Center);
899        frame.render_widget(p, inner);
900        return;
901    }
902
903    let m = &app.dynamodb_state.metrics;
904
905    // Provisioned tables get their capacity drawn as a threshold line.
906    let read_threshold = (t.provisioned_read_units > 0).then_some(t.provisioned_read_units as f64);
907    let write_threshold =
908        (t.provisioned_write_units > 0).then_some(t.provisioned_write_units as f64);
909
910    let single = vec![
911        MetricChart {
912            title: "Read capacity (units/second)",
913            data: &m.consumed_read_capacity,
914            y_axis_label: "Units/s",
915            x_axis_label: None,
916            threshold: read_threshold,
917        },
918        MetricChart {
919            title: "Write capacity (units/second)",
920            data: &m.consumed_write_capacity,
921            y_axis_label: "Units/s",
922            x_axis_label: None,
923            threshold: write_threshold,
924        },
925    ];
926
927    let multi = vec![
928        MultiDatasetChart {
929            title: "Throttled requests and events",
930            datasets: vec![
931                ("Read throttle events", m.read_throttle_events.as_slice()),
932                ("Write throttle events", m.write_throttle_events.as_slice()),
933                ("Throttled requests", m.throttled_requests.as_slice()),
934            ],
935            y_axis_label: "Count",
936            y_axis_step: 1,
937            x_axis_label: None,
938        },
939        MultiDatasetChart {
940            title: "Successful request latency (ms)",
941            datasets: vec![
942                ("GetItem", m.latency_get_item.as_slice()),
943                ("PutItem", m.latency_put_item.as_slice()),
944                ("Query", m.latency_query.as_slice()),
945                ("Scan", m.latency_scan.as_slice()),
946            ],
947            y_axis_label: "ms",
948            y_axis_step: 1,
949            x_axis_label: None,
950        },
951    ];
952
953    render_monitoring_tab(
954        frame,
955        area,
956        &single,
957        &multi,
958        &[],
959        &[],
960        app.dynamodb_state.monitoring_scroll,
961    );
962}
963
964/// Index names selectable on the Items tab: the table itself first, then every
965/// secondary index in the order the Indexes tab lists them.
966pub fn item_index_options(t: &DdbTable) -> Vec<String> {
967    let mut options = vec![format!("[Table] {}", t.name)];
968    options.extend(
969        t.global_secondary_indexes
970            .iter()
971            .chain(t.local_secondary_indexes.iter())
972            .map(|i| format!("[Index] {}", i.name)),
973    );
974    options
975}
976
977/// The index name to scan or query, or `None` for the base table.
978pub fn selected_item_index(t: &DdbTable, idx: usize) -> Option<String> {
979    if idx == 0 {
980        return None;
981    }
982    t.global_secondary_indexes
983        .iter()
984        .chain(t.local_secondary_indexes.iter())
985        .nth(idx - 1)
986        .map(|i| i.name.clone())
987}
988
989/// Strip the "(S)" type suffix a key label carries: "order_id (S)" -> "order_id".
990pub fn key_attribute_name(label: &str) -> &str {
991    label.split(" (").next().unwrap_or(label).trim()
992}
993
994/// Attribute type letter from a key label: "order_id (N)" -> "N".
995pub fn key_attribute_type(label: &str) -> String {
996    label
997        .rsplit_once('(')
998        .and_then(|(_, rest)| rest.split(')').next())
999        .unwrap_or("S")
1000        .to_string()
1001}
1002
1003/// Partition-key label ("customer_id (S)") of the selected table or index.
1004pub fn selected_partition_key_label(t: &DdbTable, idx: usize) -> String {
1005    if idx == 0 {
1006        return t.partition_key.clone();
1007    }
1008    t.global_secondary_indexes
1009        .iter()
1010        .chain(t.local_secondary_indexes.iter())
1011        .nth(idx - 1)
1012        .map(|i| i.partition_key.clone())
1013        .unwrap_or_else(|| t.partition_key.clone())
1014}
1015
1016/// Key attributes of the selected table or index, used for the "Keys only"
1017/// projection and for ordering the item columns.
1018pub fn selected_key_attributes(t: &DdbTable, idx: usize) -> Vec<String> {
1019    let (pk, sk) = if idx == 0 {
1020        (t.partition_key.clone(), t.sort_key.clone())
1021    } else {
1022        match t
1023            .global_secondary_indexes
1024            .iter()
1025            .chain(t.local_secondary_indexes.iter())
1026            .nth(idx - 1)
1027        {
1028            Some(i) => (i.partition_key.clone(), i.sort_key.clone()),
1029            None => (t.partition_key.clone(), t.sort_key.clone()),
1030        }
1031    };
1032    let mut keys = Vec::new();
1033    for label in [pk, sk] {
1034        if !label.is_empty() {
1035            keys.push(key_attribute_name(&label).to_string());
1036        }
1037    }
1038    keys
1039}
1040
1041/// Items matching the tab's text filter. For Query the text is the partition-key
1042/// value (applied server side), so only Scan filters client side.
1043pub fn filtered_items(app: &App) -> Vec<&DdbItem> {
1044    let filter = app.dynamodb_state.item_filter.to_lowercase();
1045    if app.dynamodb_state.item_operation == ItemOperation::Query || filter.is_empty() {
1046        return app.dynamodb_state.items.items.iter().collect();
1047    }
1048    app.dynamodb_state
1049        .items
1050        .items
1051        .iter()
1052        .filter(|i| {
1053            i.attributes.iter().any(|(k, v)| {
1054                k.to_lowercase().contains(&filter) || v.to_lowercase().contains(&filter)
1055            })
1056        })
1057        .collect()
1058}
1059
1060/// One column per attribute, keys first then the remaining names alphabetically.
1061struct ItemCol {
1062    label: String,
1063}
1064
1065impl Column<DdbItem> for ItemCol {
1066    fn name(&self) -> &str {
1067        &self.label
1068    }
1069    fn width(&self) -> u16 {
1070        (self.label.len().max(18) as u16).min(40)
1071    }
1072    fn render(&self, item: &DdbItem) -> (String, Style) {
1073        let value = item.get(&self.label);
1074        if value.is_empty() {
1075            ("-".to_string(), Style::default().fg(Color::DarkGray))
1076        } else {
1077            (value.to_string(), Style::default())
1078        }
1079    }
1080}
1081
1082fn item_columns(t: &DdbTable, app: &App) -> Vec<Box<dyn Column<DdbItem>>> {
1083    item_visible_column_names(t, app)
1084        .into_iter()
1085        .map(|label| Box::new(ItemCol { label }) as Box<dyn Column<DdbItem>>)
1086        .collect()
1087}
1088
1089/// Every attribute name across the loaded items: the keys of the selected table
1090/// or index first (in key order), then the remaining names alphabetically.
1091///
1092/// This is the actual column list of the DynamoDB table being explored, and it
1093/// is what both the Items table and its Preferences panel are built from — so
1094/// the toggles always match the columns on screen. Names come from the whole
1095/// result set rather than the current page, keeping the layout stable while
1096/// paging.
1097pub fn item_column_names(t: &DdbTable, app: &App) -> Vec<String> {
1098    let mut names = selected_key_attributes(t, app.dynamodb_state.item_index);
1099    let mut rest: Vec<String> = Vec::new();
1100    for item in &app.dynamodb_state.items.items {
1101        for attr in item.attributes.keys() {
1102            if !names.contains(attr) && !rest.contains(attr) {
1103                rest.push(attr.clone());
1104            }
1105        }
1106    }
1107    rest.sort();
1108    names.extend(rest);
1109    names
1110}
1111
1112/// [`item_column_names`] minus the ones hidden in Preferences. Hiding every
1113/// column would leave an empty table, so an all-hidden set falls back to all.
1114pub fn item_visible_column_names(t: &DdbTable, app: &App) -> Vec<String> {
1115    let all = item_column_names(t, app);
1116    let visible: Vec<String> = all
1117        .iter()
1118        .filter(|n| !app.dynamodb_state.item_hidden_columns.contains(n))
1119        .cloned()
1120        .collect();
1121    if visible.is_empty() {
1122        all
1123    } else {
1124        visible
1125    }
1126}
1127
1128fn render_items_tab(frame: &mut Frame, app: &App, t: &DdbTable, area: Rect) {
1129    use crate::common::render_dropdown;
1130    use crate::ui::filter::{render_filter_bar, FilterConfig, FilterControl};
1131
1132    let chunks = Layout::default()
1133        .direction(Direction::Vertical)
1134        .constraints([Constraint::Length(3), Constraint::Min(0)])
1135        .split(area);
1136
1137    let state = &app.dynamodb_state;
1138    let index_options = item_index_options(t);
1139    let index_label = index_options
1140        .get(state.item_index)
1141        .cloned()
1142        .unwrap_or_else(|| index_options[0].clone());
1143
1144    let view = state
1145        .items
1146        .prepare_view(filtered_items(app), TableWindow::SelectedPage);
1147    let pagination = if items::results_complete(app) {
1148        render_pagination_text(view.current_page, view.total_pages)
1149    } else {
1150        render_partial_pagination(view.current_page, view.total_pages)
1151    };
1152
1153    let placeholder = if state.item_operation == ItemOperation::Query {
1154        "Enter a partition key value to query"
1155    } else {
1156        "Filter the scanned items"
1157    };
1158
1159    render_filter_bar(
1160        frame,
1161        FilterConfig {
1162            filter_text: &state.item_filter,
1163            placeholder,
1164            mode: app.mode,
1165            is_input_focused: state.input_focus == InputFocus::Filter,
1166            controls: vec![
1167                FilterControl {
1168                    text: state.item_operation.name().to_string(),
1169                    is_focused: state.input_focus == ITEM_OPERATION,
1170                },
1171                FilterControl {
1172                    text: index_label,
1173                    is_focused: state.input_focus == ITEM_INDEX,
1174                },
1175                FilterControl {
1176                    text: state.item_projection.name().to_string(),
1177                    is_focused: state.input_focus == ITEM_PROJECTION,
1178                },
1179                FilterControl {
1180                    text: pagination.clone(),
1181                    is_focused: state.input_focus == InputFocus::Pagination,
1182                },
1183            ],
1184            area: chunks[0],
1185        },
1186    );
1187
1188    if state.items_loading && !state.items_append {
1189        let block = crate::ui::titled_block(t.name.clone());
1190        let inner = block.inner(chunks[1]);
1191        frame.render_widget(block, chunks[1]);
1192        let p = Paragraph::new("Loading items...")
1193            .style(Style::default().fg(Color::DarkGray))
1194            .alignment(Alignment::Center);
1195        frame.render_widget(p, inner);
1196        return;
1197    }
1198
1199    // Query needs a partition-key value before it can return anything.
1200    if state.item_operation == ItemOperation::Query && state.item_filter.is_empty() {
1201        let block = crate::ui::titled_block(t.name.clone());
1202        let inner = block.inner(chunks[1]);
1203        frame.render_widget(block, chunks[1]);
1204        let key = selected_key_attributes(t, state.item_index)
1205            .first()
1206            .cloned()
1207            .unwrap_or_default();
1208        let p = Paragraph::new(format!("Enter a value for {} to run the query", key))
1209            .style(Style::default().fg(Color::DarkGray))
1210            .alignment(Alignment::Center);
1211        frame.render_widget(p, inner);
1212        return;
1213    }
1214
1215    let columns = item_columns(t, app);
1216
1217    let config = TableConfig {
1218        items: view.items,
1219        selected_index: view.selected_index,
1220        expanded_index: view.expanded_index,
1221        columns: &columns,
1222        sort_column: "",
1223        sort_direction: SortDirection::Asc,
1224        title: format_title(&t.name),
1225        area: chunks[1],
1226        get_expanded_content: Some(Box::new(|i: &DdbItem| expanded_from_columns(&columns, i))),
1227        is_active: app.mode != crate::keymap::Mode::FilterInput,
1228    };
1229    render_table(frame, config);
1230
1231    // Dropdown overlays render last so they sit on top of the table. An open
1232    // dropdown always lists every option so the selection can be changed.
1233    {
1234        let after_operation = (index_label_width(&index_options, state.item_index)
1235            + state.item_projection.name().len()) as u16
1236            + pagination.len() as u16
1237            + 9;
1238        if state.input_focus == ITEM_OPERATION {
1239            let names: Vec<&str> = ItemOperation::ALL.iter().map(|o| o.name()).collect();
1240            let selected = ItemOperation::ALL
1241                .iter()
1242                .position(|o| *o == state.item_operation)
1243                .unwrap_or(0);
1244            render_dropdown(frame, &names, selected, chunks[0], after_operation);
1245        } else if state.input_focus == ITEM_INDEX {
1246            let after = (state.item_projection.name().len() + pagination.len()) as u16 + 6;
1247            render_dropdown(
1248                frame,
1249                &index_options,
1250                state.item_index.min(index_options.len().saturating_sub(1)),
1251                chunks[0],
1252                after,
1253            );
1254        } else if state.input_focus == ITEM_PROJECTION {
1255            let names: Vec<&str> = ItemProjection::ALL.iter().map(|p| p.name()).collect();
1256            let selected = ItemProjection::ALL
1257                .iter()
1258                .position(|p| *p == state.item_projection)
1259                .unwrap_or(0);
1260            let after = pagination.len() as u16 + 3;
1261            render_dropdown(frame, &names, selected, chunks[0], after);
1262        }
1263    }
1264}
1265
1266/// Rendered width of the index control, used to place the dropdown overlay.
1267fn index_label_width(options: &[String], selected: usize) -> usize {
1268    options
1269        .get(selected)
1270        .or_else(|| options.first())
1271        .map(|s| s.len())
1272        .unwrap_or(0)
1273}
1274
1275fn render_tags_tab(frame: &mut Frame, app: &App, area: Rect) {
1276    tags::render(
1277        frame,
1278        area,
1279        TagsConfig {
1280            table: &app.dynamodb_state.tags_table,
1281            items: &app.dynamodb_state.tags_table.items,
1282            columns: tags::columns(),
1283            sort: TagSort::Key,
1284            window: TableWindow::SelectedPage,
1285            focus: app.dynamodb_state.input_focus,
1286            mode: app.mode,
1287            loading: app.dynamodb_state.tags_loading,
1288        },
1289    );
1290}
1291
1292pub fn render_tables_list(frame: &mut Frame, app: &App, area: Rect) {
1293    let chunks = Layout::default()
1294        .direction(Direction::Vertical)
1295        .constraints([Constraint::Length(3), Constraint::Min(0)])
1296        .split(area);
1297
1298    let view = app
1299        .dynamodb_state
1300        .tables
1301        .prepare_view(filtered_dynamodb_tables(app), TableWindow::SelectedPage);
1302    let pagination = render_pagination_text(view.current_page, view.total_pages);
1303
1304    render_simple_filter(
1305        frame,
1306        chunks[0],
1307        SimpleFilterConfig {
1308            filter_text: &app.dynamodb_state.tables.filter,
1309            placeholder: "Search by table name, status, or partition key",
1310            pagination: &pagination,
1311            mode: app.mode,
1312            is_input_focused: app.dynamodb_state.input_focus == InputFocus::Filter,
1313            is_pagination_focused: app.dynamodb_state.input_focus == InputFocus::Pagination,
1314        },
1315    );
1316
1317    let title = format_title(&format!("Tables ({})", view.total_items));
1318
1319    let columns: Vec<Box<dyn Column<DdbTable>>> = app
1320        .dynamodb_visible_column_ids
1321        .iter()
1322        .filter_map(|col_id| {
1323            table::Column::from_id(col_id).map(|col| Box::new(col) as Box<dyn Column<DdbTable>>)
1324        })
1325        .collect();
1326
1327    let config = TableConfig {
1328        items: view.items,
1329        selected_index: view.selected_index,
1330        expanded_index: view.expanded_index,
1331        columns: &columns,
1332        sort_column: "Name",
1333        sort_direction: SortDirection::Asc,
1334        title,
1335        area: chunks[1],
1336        get_expanded_content: Some(Box::new(|t: &DdbTable| expanded_from_columns(&columns, t))),
1337        is_active: app.mode != crate::keymap::Mode::FilterInput,
1338    };
1339
1340    render_table(frame, config);
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::*;
1346    use rusticity_core::dynamodb::DdbIndex;
1347
1348    fn make_table(name: &str, pk: &str) -> DdbTable {
1349        DdbTable {
1350            name: name.to_string(),
1351            status: "ACTIVE".to_string(),
1352            partition_key: pk.to_string(),
1353            read_capacity_mode: "On-demand".to_string(),
1354            write_capacity_mode: "On-demand".to_string(),
1355            table_class: "Standard".to_string(),
1356            deletion_protection: "Disabled".to_string(),
1357            ..Default::default()
1358        }
1359    }
1360
1361    #[test]
1362    fn test_prepared_items_second_page_and_stale_selection() {
1363        use crate::common::PageSize;
1364        let mut app = app_with_table();
1365        app.dynamodb_state.current_table = Some("orders".into());
1366        app.dynamodb_state.detail_tab = DetailTab::Items;
1367        app.dynamodb_state.items.items = (0..25)
1368            .map(|i| item_with(&[("order_id", &format!("record{i:02}")), ("status", "MATCH")]))
1369            .collect();
1370        app.dynamodb_state
1371            .items
1372            .items
1373            .push(item_with(&[("order_id", "excluded")]));
1374        app.dynamodb_state.item_filter = "match".into();
1375        app.dynamodb_state.items.page_size = PageSize::Ten;
1376        app.dynamodb_state.items.selected = 12;
1377        app.dynamodb_state.items.expanded_item = Some(11);
1378        app.dynamodb_state.items.next_token = Some("unchanged-server-cursor".into());
1379
1380        let content = render_to_string(&app, 200, 45);
1381        assert!(content.contains(&render_pagination_text(1, 3)));
1382        assert!(content.contains("record10"));
1383        assert!(content.contains("record19"));
1384        assert!(content.contains("▼ record11"));
1385        assert!(content.contains("► record12"));
1386        assert!(!content.contains("record09"));
1387        assert!(!content.contains("record20"));
1388        assert!(!content.contains("excluded"));
1389
1390        // A narrower result set can arrive before navigation state is reset.
1391        app.dynamodb_state.item_filter = "missing".into();
1392        let content = render_to_string(&app, 200, 45);
1393        assert!(!content.contains("record12"));
1394        assert_eq!(app.dynamodb_state.items.selected, 12);
1395        assert_eq!(app.dynamodb_state.items.expanded_item, Some(11));
1396        assert_eq!(
1397            app.dynamodb_state.items.next_token.as_deref(),
1398            Some("unchanged-server-cursor")
1399        );
1400        assert!(!app.dynamodb_state.items_loading);
1401    }
1402
1403    #[test]
1404    fn test_summary_and_general_borders_follow_content_without_blank_rows() {
1405        use ratatui::{backend::TestBackend, Terminal};
1406
1407        let mut app = app_with_table();
1408        app.dynamodb_state.current_table = Some("orders".into());
1409        app.dynamodb_state.detail_tab = DetailTab::Settings;
1410        app.dynamodb_state.tables.items[0].creation_time = "2026-07-27T15:11:44Z".into();
1411        app.dynamodb_state.tables.items[0].encryption_type = "Owned by Amazon DynamoDB".into();
1412        app.dynamodb_state.settings.ttl_status = "Enabled".into();
1413        app.dynamodb_state.settings.ttl_attribute = "expires_at".into();
1414
1415        for width in [90, 160, 300] {
1416            let mut terminal = Terminal::new(TestBackend::new(width, 60)).unwrap();
1417            // Also exercise a pane that does not start at the terminal origin.
1418            let area = Rect::new(2, 1, width - 4, 58);
1419            terminal
1420                .draw(|f| render_table_detail(f, &app, area))
1421                .unwrap();
1422            let buffer = terminal.backend().buffer();
1423            let rows: Vec<String> = (area.y..area.bottom())
1424                .map(|y| {
1425                    (area.x..area.right())
1426                        .map(|x| buffer[(x, y)].symbol())
1427                        .collect()
1428                })
1429                .collect();
1430            for title in ["Summary", "General information"] {
1431                let top = rows.iter().position(|row| row.contains(title)).unwrap();
1432                let bottom = (top + 1..rows.len())
1433                    .find(|&y| rows[y].starts_with('╰'))
1434                    .unwrap();
1435                assert!(bottom > top + 1);
1436                assert!(
1437                    rows[bottom - 1]
1438                        .chars()
1439                        .any(|c| !c.is_whitespace() && c != '│'),
1440                    "{title} has a blank row above its bottom border at width {width}"
1441                );
1442                for row in &rows[top + 1..bottom] {
1443                    assert!(!row.contains('╰'), "Content overlaps a border: {row}");
1444                }
1445            }
1446            let content = rows.join("\n");
1447            for label in [
1448                "Table name:",
1449                "Status:",
1450                "Partition key:",
1451                "Sort key:",
1452                "Read capacity mode:",
1453                "Write capacity mode:",
1454                "Table class:",
1455                "Total size:",
1456                "Item count:",
1457                "Indexes:",
1458                "Creation time:",
1459                "ARN:",
1460                "Table ID:",
1461                "Encryption type:",
1462                "KMS key ARN:",
1463                "Time to Live (TTL):",
1464                "TTL attribute:",
1465            ] {
1466                assert!(content.contains(label), "Missing {label} at width {width}");
1467            }
1468            assert_eq!(content.matches("Deletion protection:").count(), 2);
1469            let summary_bottom = rows.iter().position(|row| row.starts_with('╰')).unwrap();
1470            assert!(rows[summary_bottom + 1].starts_with("Items"));
1471            assert!(rows[summary_bottom + 2].contains("General information"));
1472            let general_top = rows
1473                .iter()
1474                .position(|row| row.contains("General information"))
1475                .unwrap();
1476            let general_bottom = (general_top + 1..rows.len())
1477                .find(|&y| rows[y].starts_with('╰'))
1478                .unwrap();
1479            assert!(rows[general_bottom + 1].contains("Capacity"));
1480            if width == 300 {
1481                assert_eq!(summary_bottom, 5, "Four content rows plus two borders");
1482                assert_eq!(
1483                    general_bottom - general_top,
1484                    4,
1485                    "Three content rows plus two borders"
1486                );
1487            }
1488        }
1489    }
1490
1491    #[test]
1492    fn test_summary_and_general_render_in_tiny_viewports() {
1493        use ratatui::{backend::TestBackend, Terminal};
1494        let mut app = app_with_table();
1495        app.dynamodb_state.current_table = Some("orders".into());
1496        app.dynamodb_state.detail_tab = DetailTab::Settings;
1497        for width in [1, 2, 12, 80] {
1498            for height in [1, 2, 3, 8] {
1499                let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1500                terminal
1501                    .draw(|f| render_table_detail(f, &app, f.area()))
1502                    .unwrap();
1503            }
1504        }
1505    }
1506
1507    fn items_rows(app: &App) -> Vec<String> {
1508        use ratatui::{backend::TestBackend, Terminal};
1509        let mut terminal = Terminal::new(TestBackend::new(220, 20)).unwrap();
1510        let table = current_table(app).unwrap();
1511        terminal
1512            .draw(|f| render_items_tab(f, app, table, f.area()))
1513            .unwrap();
1514        let buffer = terminal.backend().buffer();
1515        (0..20)
1516            .map(|y| (0..220).map(|x| buffer[(x, y)].symbol()).collect())
1517            .collect()
1518    }
1519
1520    #[test]
1521    fn test_items_plain_title_has_no_result_summary_or_status_row() {
1522        let mut app = app_with_table();
1523        app.dynamodb_state.current_table = Some("orders".into());
1524        app.dynamodb_state.items.items = (0..152)
1525            .map(|i| item_with(&[("order_id", &format!("record{i:03}"))]))
1526            .collect();
1527        app.dynamodb_state.items_context = items::context(&app);
1528        let rows = items_rows(&app);
1529        // Filter consumes three rows; the table border is immediately next.
1530        assert!(rows[3].starts_with("╭─ orders ─"));
1531        let border = rows[3].strip_prefix("╭─ orders ─").unwrap();
1532        assert!(border.strip_suffix('╮').unwrap().chars().all(|c| c == '─'));
1533        assert!(rows[4].contains("order_id"));
1534        assert!(rows[5].contains("record000"));
1535        let screen = rows.join("\n");
1536        assert!(!screen.contains("loaded"));
1537        assert!(!screen.contains("matching"));
1538        assert!(!screen.contains("No more pages"));
1539    }
1540
1541    #[test]
1542    fn test_items_placeholders_keep_messages_in_body_not_title() {
1543        let mut app = app_with_table();
1544        app.dynamodb_state.current_table = Some("orders".into());
1545        app.dynamodb_state.items_loading = true;
1546        let rows = items_rows(&app);
1547        assert!(rows[3].starts_with("╭─ orders ─"));
1548        assert!(!rows[3].contains("Loading"));
1549        assert!(rows[4].contains("Loading items..."));
1550
1551        app.dynamodb_state.items_loading = false;
1552        app.dynamodb_state.item_operation = ItemOperation::Query;
1553        let rows = items_rows(&app);
1554        assert!(rows[3].starts_with("╭─ orders ─"));
1555        assert!(!rows[3].contains("query"));
1556        assert!(rows[4].contains("to run the query"));
1557    }
1558
1559    #[test]
1560    fn test_state_default() {
1561        let state = State::new();
1562        assert_eq!(state.input_focus, InputFocus::Filter);
1563        assert!(state.tables.items.is_empty());
1564        assert_eq!(state.current_table, None);
1565        assert_eq!(state.detail_tab, DetailTab::Items);
1566        assert!(state.item_hidden_columns.is_empty());
1567    }
1568
1569    #[test]
1570    fn test_filtered_by_name_status_partition_key() {
1571        let mut app = App::new_without_client("default".to_string(), None);
1572        app.dynamodb_state.tables.items = vec![
1573            make_table("orders", "order_id (S)"),
1574            make_table("users", "user_id (S)"),
1575        ];
1576
1577        app.dynamodb_state.tables.filter = "ord".to_string();
1578        assert_eq!(filtered_dynamodb_tables(&app).len(), 1);
1579
1580        app.dynamodb_state.tables.filter = "user_id".to_string();
1581        assert_eq!(filtered_dynamodb_tables(&app).len(), 1);
1582
1583        app.dynamodb_state.tables.filter = "active".to_string();
1584        assert_eq!(filtered_dynamodb_tables(&app).len(), 2);
1585
1586        app.dynamodb_state.tables.filter = String::new();
1587        assert_eq!(filtered_dynamodb_tables(&app).len(), 2);
1588    }
1589
1590    #[test]
1591    fn test_detail_tab_cycles_through_all_tabs() {
1592        let mut tab = DetailTab::Items;
1593        let mut seen = vec![tab];
1594        for _ in 0..DetailTab::ALL.len() - 1 {
1595            tab = tab.next();
1596            seen.push(tab);
1597        }
1598        assert_eq!(seen.len(), 9);
1599        assert_eq!(tab.next(), DetailTab::Items);
1600        assert_eq!(DetailTab::ALL.len(), 9);
1601        // Items then Settings lead; Monitor follows Indexes; Tags is last.
1602        assert_eq!(DetailTab::ALL[0], DetailTab::Items);
1603        assert_eq!(DetailTab::ALL[1], DetailTab::Settings);
1604        assert_eq!(DetailTab::ALL[2], DetailTab::Indexes);
1605        assert_eq!(DetailTab::ALL[3], DetailTab::Monitor);
1606        assert_eq!(DetailTab::ALL[DetailTab::ALL.len() - 1], DetailTab::Tags);
1607    }
1608
1609    #[test]
1610    fn test_console_tab_ids_are_unique() {
1611        let mut ids: Vec<&str> = DetailTab::ALL.iter().map(|t| t.console_tab()).collect();
1612        ids.sort_unstable();
1613        let count = ids.len();
1614        ids.dedup();
1615        assert_eq!(ids.len(), count);
1616    }
1617
1618    #[test]
1619    fn test_format_capacity() {
1620        assert_eq!(format_capacity("On-demand", 0, "RCU"), "On-demand");
1621        assert_eq!(
1622            format_capacity("Provisioned", 5, "RCU"),
1623            "Provisioned (5 RCU)"
1624        );
1625        // Provisioned with no units falls back to the bare mode.
1626        assert_eq!(format_capacity("Provisioned", 0, "WCU"), "Provisioned");
1627    }
1628
1629    #[test]
1630    fn test_enabled_disabled_color() {
1631        assert_eq!(enabled_disabled_color("Enabled"), Color::Green);
1632        assert_eq!(enabled_disabled_color("Disabled"), Color::Yellow);
1633        assert_eq!(enabled_disabled_color(""), Color::White);
1634    }
1635
1636    #[test]
1637    fn test_summary_includes_keys_capacity_and_indexes() {
1638        let mut t = make_table("orders", "order_id (S)");
1639        t.sort_key = "created_at (N)".to_string();
1640        t.read_capacity_mode = "Provisioned".to_string();
1641        t.provisioned_read_units = 5;
1642        t.write_capacity_mode = "Provisioned".to_string();
1643        t.provisioned_write_units = 7;
1644        t.total_size_bytes = 2048;
1645        t.item_count = 11;
1646        t.global_secondary_indexes = vec![DdbIndex::default()];
1647
1648        let text: String = build_summary_lines(&t)
1649            .iter()
1650            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1651            .collect::<Vec<_>>()
1652            .join("");
1653        assert!(text.contains("order_id (S)"));
1654        assert!(text.contains("created_at (N)"));
1655        assert!(text.contains("Provisioned (5 RCU)"));
1656        assert!(text.contains("Provisioned (7 WCU)"));
1657        assert!(text.contains("Standard"));
1658    }
1659
1660    #[test]
1661    fn test_summary_shows_dash_when_no_sort_key() {
1662        let t = make_table("orders", "order_id (S)");
1663        let text: String = build_summary_lines(&t)
1664            .iter()
1665            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1666            .collect::<Vec<_>>()
1667            .join("");
1668        assert!(text.contains("Sort key"));
1669        assert!(text.contains('-'));
1670    }
1671
1672    #[test]
1673    fn test_detail_fixed_columns() {
1674        assert_eq!(
1675            detail_fixed_columns(DetailTab::Indexes),
1676            Some(&INDEXES_COLUMNS[..])
1677        );
1678        assert_eq!(
1679            detail_fixed_columns(DetailTab::GlobalTables),
1680            Some(&REPLICAS_COLUMNS[..])
1681        );
1682        assert_eq!(
1683            detail_fixed_columns(DetailTab::Backups),
1684            Some(&BACKUPS_COLUMNS[..])
1685        );
1686        assert_eq!(
1687            detail_fixed_columns(DetailTab::Tags),
1688            Some(&TAGS_COLUMNS[..])
1689        );
1690        assert_eq!(detail_fixed_columns(DetailTab::Settings), None);
1691        assert_eq!(detail_fixed_columns(DetailTab::Monitor), None);
1692        assert_eq!(detail_fixed_columns(DetailTab::AdditionalSettings), None);
1693    }
1694
1695    #[test]
1696    fn test_current_table_lookup() {
1697        let mut app = App::new_without_client("default".to_string(), None);
1698        app.dynamodb_state.tables.items = vec![make_table("orders", "pk (S)")];
1699        assert!(current_table(&app).is_none());
1700        app.dynamodb_state.current_table = Some("orders".to_string());
1701        assert_eq!(current_table(&app).map(|t| t.name.as_str()), Some("orders"));
1702    }
1703
1704    /// Render the whole UI to a test backend and return the flattened content.
1705    fn render_to_string(app: &App, width: u16, height: u16) -> String {
1706        use ratatui::backend::TestBackend;
1707        use ratatui::Terminal;
1708        let backend = TestBackend::new(width, height);
1709        let mut terminal = Terminal::new(backend).unwrap();
1710        terminal.draw(|f| crate::ui::render(f, app)).unwrap();
1711        terminal
1712            .backend()
1713            .buffer()
1714            .content()
1715            .iter()
1716            .map(|c| c.symbol())
1717            .collect::<String>()
1718    }
1719
1720    fn app_with_table() -> App {
1721        let mut app = App::new_without_client("default".to_string(), None);
1722        app.current_service = crate::app::Service::DynamoDbTables;
1723        app.service_selected = true;
1724        app.mode = crate::keymap::Mode::Normal;
1725        let mut t = make_table("orders", "order_id (S)");
1726        t.sort_key = "created_at (N)".to_string();
1727        t.table_arn = "arn:aws:dynamodb:us-east-2:123456789012:table/orders".to_string();
1728        t.global_secondary_indexes = vec![DdbIndex {
1729            name: "customer-index".to_string(),
1730            index_type: "Global".to_string(),
1731            status: "ACTIVE".to_string(),
1732            partition_key: "customer_id (S)".to_string(),
1733            projection: "KEYS_ONLY".to_string(),
1734            ..Default::default()
1735        }];
1736        app.dynamodb_state.tables.items = vec![t];
1737        app
1738    }
1739
1740    /// A `DdbItem` with the given attribute pairs.
1741    fn item_with(attrs: &[(&str, &str)]) -> DdbItem {
1742        let mut item = DdbItem::default();
1743        for (k, v) in attrs {
1744            item.attributes.insert(k.to_string(), v.to_string());
1745        }
1746        item
1747    }
1748
1749    #[test]
1750    fn test_render_tables_list() {
1751        let app = app_with_table();
1752        // Wide enough for all 12 default columns.
1753        let content = render_to_string(&app, 240, 30);
1754        assert!(content.contains("Tables (1)"));
1755        assert!(content.contains("orders"));
1756        assert!(content.contains("Partition key"));
1757        assert!(content.contains("Replication Regions"));
1758        assert!(content.contains("Table class"));
1759    }
1760
1761    #[test]
1762    fn test_render_detail_every_tab() {
1763        let mut app = app_with_table();
1764        app.dynamodb_state.current_table = Some("orders".to_string());
1765        for tab in DetailTab::ALL {
1766            app.dynamodb_state.detail_tab = *tab;
1767            let content = render_to_string(&app, 160, 40);
1768            // The ribbon shows on every tab; Items is the only one without the
1769            // Summary pane.
1770            assert!(content.contains(tab.name()), "tab {:?}", tab);
1771            if *tab != DetailTab::Items {
1772                assert!(content.contains("Summary"), "tab {:?}", tab);
1773            } else {
1774                assert!(!content.contains("Summary"), "tab {:?}", tab);
1775            }
1776        }
1777    }
1778
1779    #[test]
1780    fn test_render_indexes_tab_shows_index_rows() {
1781        let mut app = app_with_table();
1782        app.dynamodb_state.current_table = Some("orders".to_string());
1783        app.dynamodb_state.detail_tab = DetailTab::Indexes;
1784        let content = render_to_string(&app, 200, 40);
1785        assert!(content.contains("Indexes (1)"));
1786        assert!(content.contains("customer-index"));
1787    }
1788
1789    #[test]
1790    fn test_preferences_uses_tab_columns_not_list_columns() {
1791        // Regression: detail tabs must not expose the Tables list columns in the
1792        // preferences selector.
1793        let mut app = app_with_table();
1794        app.mode = crate::keymap::Mode::ColumnSelector;
1795
1796        // List view → table columns.
1797        let content = render_to_string(&app, 160, 40);
1798        assert!(content.contains("Partition key"));
1799        assert!(content.contains("Read capacity mode"));
1800
1801        // Indexes tab → the 10 fixed index columns (the summary pane behind the
1802        // popup still shows table fields, so assert on the counts).
1803        app.dynamodb_state.current_table = Some("orders".to_string());
1804        app.dynamodb_state.detail_tab = DetailTab::Indexes;
1805        assert_eq!(crate::dynamodb::actions::column_count(&app), 10);
1806        assert_eq!(crate::dynamodb::actions::column_selector_max(&app), 10 + 6);
1807
1808        // Global tables tab → the 5 replica columns only.
1809        app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1810        assert_eq!(crate::dynamodb::actions::column_count(&app), 5);
1811        let content = render_to_string(&app, 160, 40);
1812        assert!(content.contains("Region"));
1813
1814        // A non-table tab falls back to the list columns.
1815        app.dynamodb_state.detail_tab = DetailTab::Settings;
1816        assert_eq!(
1817            crate::dynamodb::actions::column_count(&app),
1818            app.dynamodb_column_ids.len()
1819        );
1820    }
1821
1822    #[test]
1823    fn test_detail_tab_change_marks_lazy_data_for_reload() {
1824        let mut app = app_with_table();
1825        app.dynamodb_state.current_table = Some("orders".to_string());
1826        app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1827
1828        // Backups needs both the backup list and the PITR settings.
1829        crate::dynamodb::actions::next_detail_tab(&mut app);
1830        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Backups);
1831        assert!(app.dynamodb_state.backups_loading);
1832        assert!(app.dynamodb_state.settings_loading);
1833        app.dynamodb_state.settings_loading = false;
1834
1835        crate::dynamodb::actions::next_detail_tab(&mut app);
1836        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::ExportsStreams);
1837
1838        // Additional settings loads the resource-based policies.
1839        crate::dynamodb::actions::next_detail_tab(&mut app);
1840        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::AdditionalSettings);
1841        assert!(app.dynamodb_state.permissions_loading);
1842
1843        // Tags loads the tag list.
1844        crate::dynamodb::actions::next_detail_tab(&mut app);
1845        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Tags);
1846        assert!(app.dynamodb_state.tags_loading);
1847
1848        // Wrapping around to Items schedules a scan, then Settings loads TTL.
1849        crate::dynamodb::actions::next_detail_tab(&mut app);
1850        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
1851        assert!(app.dynamodb_state.items_loading);
1852
1853        crate::dynamodb::actions::next_detail_tab(&mut app);
1854        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
1855        assert!(app.dynamodb_state.settings_loading);
1856    }
1857
1858    #[test]
1859    fn test_tags_tab_renders_tag_rows() {
1860        let mut app = app_with_table();
1861        app.dynamodb_state.current_table = Some("orders".to_string());
1862        app.dynamodb_state.detail_tab = DetailTab::Tags;
1863        app.dynamodb_state.tags_table.items = vec![
1864            ("Name".to_string(), "rusticity-test".to_string()),
1865            ("Owner".to_string(), "azzhipa".to_string()),
1866        ];
1867        let content = render_to_string(&app, 200, 40);
1868        assert!(content.contains("Tags (2)"));
1869        assert!(content.contains("rusticity-test"));
1870        assert_eq!(crate::dynamodb::actions::column_count(&app), 2);
1871
1872        app.dynamodb_state.tags_loading = true;
1873        let content = render_to_string(&app, 200, 40);
1874        assert!(content.contains("Loading tags"));
1875    }
1876
1877    #[test]
1878    fn test_timestamps_use_the_common_utc_formatter() {
1879        // Raw SDK timestamps must never reach the screen.
1880        let mut t = make_table("orders", "order_id (S)");
1881        t.creation_time = "2026-07-27T15:11:44.813999Z".to_string();
1882        let text: String = build_summary_lines(&t)
1883            .iter()
1884            .flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
1885            .collect::<Vec<_>>()
1886            .join("");
1887        assert!(text.contains("2026-07-27 15:11:44 (UTC)"), "{}", text);
1888        assert!(!text.contains("2026-07-27T15:11:44.813999Z"));
1889
1890        // The Backups pane formats the PITR restore points too.
1891        let mut app = app_with_table();
1892        app.dynamodb_state.tables.items[0].creation_time = t.creation_time.clone();
1893        app.dynamodb_state.current_table = Some("orders".to_string());
1894        app.dynamodb_state.detail_tab = DetailTab::Backups;
1895        app.dynamodb_state.settings = DdbTableSettings {
1896            pitr_status: "Enabled".to_string(),
1897            recovery_period_days: 35,
1898            earliest_restorable_time: "2026-07-27T15:12:15Z".to_string(),
1899            latest_restorable_time: "2026-07-27T15:44:10.181999Z".to_string(),
1900            ..Default::default()
1901        };
1902        let content = render_to_string(&app, 200, 40);
1903        assert!(content.contains("2026-07-27 15:12:15 (UTC)"));
1904        assert!(content.contains("2026-07-27 15:44:10 (UTC)"));
1905        assert!(!content.contains(".181999Z"));
1906    }
1907
1908    #[test]
1909    fn test_selecting_a_table_opens_items_and_schedules_a_scan() {
1910        let mut app = app_with_table();
1911        crate::dynamodb::actions::select_item(&mut app);
1912        assert_eq!(app.dynamodb_state.current_table.as_deref(), Some("orders"));
1913        // Items is the landing tab and its scan is queued right away.
1914        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
1915        assert!(app.dynamodb_state.items_loading);
1916        assert!(needs_items_load(&app));
1917        // TTL is only fetched once the Settings tab is opened.
1918        assert!(!app.dynamodb_state.settings_loading);
1919
1920        crate::dynamodb::actions::next_detail_tab(&mut app);
1921        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
1922        assert!(app.dynamodb_state.settings_loading);
1923    }
1924
1925    #[test]
1926    fn test_backups_tab_shows_pitr_pane() {
1927        let mut app = app_with_table();
1928        app.dynamodb_state.current_table = Some("orders".to_string());
1929        app.dynamodb_state.detail_tab = DetailTab::Backups;
1930        app.dynamodb_state.settings = DdbTableSettings {
1931            pitr_status: "Enabled".to_string(),
1932            recovery_period_days: 35,
1933            earliest_restorable_time: "2026-07-27T15:12:15Z".to_string(),
1934            latest_restorable_time: "2026-07-27T15:33:18Z".to_string(),
1935            ..Default::default()
1936        };
1937        let content = render_to_string(&app, 200, 40);
1938        assert!(content.contains("Point-in-time recovery (PITR)"));
1939        assert!(content.contains("35 days"));
1940        assert!(content.contains("Earliest restore point"));
1941        assert!(content.contains("Latest restore point"));
1942    }
1943
1944    #[test]
1945    fn test_global_tables_tab_shows_settings_replication() {
1946        let mut app = app_with_table();
1947        app.dynamodb_state.current_table = Some("orders".to_string());
1948        app.dynamodb_state.detail_tab = DetailTab::GlobalTables;
1949        let content = render_to_string(&app, 200, 40);
1950        assert!(content.contains("Settings replication"));
1951        assert!(content.contains("Disabled"));
1952        assert!(content.contains("Replicas (0)"));
1953    }
1954
1955    #[test]
1956    fn test_additional_settings_shows_policy_panes() {
1957        let mut app = app_with_table();
1958        app.dynamodb_state.current_table = Some("orders".to_string());
1959        app.dynamodb_state.detail_tab = DetailTab::AdditionalSettings;
1960        let content = render_to_string(&app, 200, 40);
1961        assert!(content.contains("Resource-based policy for table"));
1962        assert!(content.contains("No table resource-based policy to display"));
1963        assert!(content.contains("No stream resource-based policy to display"));
1964
1965        app.dynamodb_state.table_policy = Some("{\n  \"Version\": \"2012-10-17\"\n}".to_string());
1966        let content = render_to_string(&app, 200, 40);
1967        assert!(content.contains("2012-10-17"));
1968    }
1969
1970    #[test]
1971    fn test_settings_tab_shows_ttl() {
1972        let mut app = app_with_table();
1973        app.dynamodb_state.current_table = Some("orders".to_string());
1974        app.dynamodb_state.detail_tab = DetailTab::Settings;
1975        app.dynamodb_state.settings = DdbTableSettings {
1976            ttl_status: "Enabled".to_string(),
1977            ttl_attribute: "expires_at".to_string(),
1978            ..Default::default()
1979        };
1980        let content = render_to_string(&app, 200, 40);
1981        assert!(content.contains("Time to Live (TTL)"));
1982        assert!(content.contains("expires_at"));
1983    }
1984
1985    #[test]
1986    fn test_console_url_follows_active_tab() {
1987        let mut app = app_with_table();
1988        app.dynamodb_state.current_table = Some("orders".to_string());
1989        app.dynamodb_state.detail_tab = DetailTab::Indexes;
1990        let url = crate::dynamodb::actions::console_url(&app);
1991        assert!(url.contains("#table?name=orders&tab=indexes"), "{}", url);
1992
1993        app.dynamodb_state.detail_tab = DetailTab::Monitor;
1994        let url = crate::dynamodb::actions::console_url(&app);
1995        assert!(url.contains("tab=monitoring"), "{}", url);
1996
1997        app.dynamodb_state.current_table = None;
1998        let url = crate::dynamodb::actions::console_url(&app);
1999        assert!(url.contains("tab=overview"), "{}", url);
2000    }
2001
2002    #[test]
2003    fn test_monitor_tab_loads_metrics_and_scrolls() {
2004        use crate::ui::monitoring::MonitoringState;
2005        let mut app = app_with_table();
2006        app.dynamodb_state.current_table = Some("orders".to_string());
2007        app.dynamodb_state.detail_tab = DetailTab::Indexes;
2008
2009        // Entering Monitor flags a metrics load and resets the scroll.
2010        app.dynamodb_state.monitoring_scroll = 3;
2011        crate::dynamodb::actions::next_detail_tab(&mut app);
2012        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Monitor);
2013        assert!(app.dynamodb_state.metrics_loading);
2014        assert_eq!(app.dynamodb_state.monitoring_scroll, 0);
2015
2016        // Ctrl+D / Ctrl+U scroll within the chart count and clamp at both ends.
2017        app.dynamodb_state.set_metrics_loading(false);
2018        for _ in 0..MONITORING_CHARTS + 2 {
2019            crate::dynamodb::actions::scroll_down_detail(&mut app);
2020        }
2021        assert_eq!(app.dynamodb_state.monitoring_scroll, MONITORING_CHARTS - 1);
2022        for _ in 0..MONITORING_CHARTS + 2 {
2023            crate::dynamodb::actions::scroll_up_detail(&mut app);
2024        }
2025        assert_eq!(app.dynamodb_state.monitoring_scroll, 0);
2026    }
2027
2028    #[test]
2029    fn test_monitor_tab_renders_charts() {
2030        let mut app = app_with_table();
2031        app.dynamodb_state.current_table = Some("orders".to_string());
2032        app.dynamodb_state.detail_tab = DetailTab::Monitor;
2033        app.dynamodb_state.metrics = rusticity_core::dynamodb::DdbMonitoringData {
2034            consumed_read_capacity: vec![(0, 1.0), (60, 2.0)],
2035            consumed_write_capacity: vec![(0, 0.5)],
2036            ..Default::default()
2037        };
2038        let content = render_to_string(&app, 200, 60);
2039        assert!(content.contains("Read capacity"));
2040        assert!(content.contains("Write capacity"));
2041
2042        // While loading, the tab shows a placeholder instead of charts.
2043        app.dynamodb_state.metrics_loading = true;
2044        let content = render_to_string(&app, 200, 60);
2045        assert!(content.contains("Loading metrics"));
2046    }
2047
2048    #[test]
2049    fn test_items_loading_pane_names_the_table() {
2050        let mut app = app_with_table();
2051        app.dynamodb_state.current_table = Some("orders".to_string());
2052        app.dynamodb_state.detail_tab = DetailTab::Items;
2053        app.dynamodb_state.items_loading = true;
2054        let content = render_to_string(&app, 200, 40);
2055        // The pane is titled with the table name only — the ribbon already says
2056        // which tab is open.
2057        assert!(content.contains("─ orders ─"));
2058        assert!(!content.contains("Items returned"));
2059        assert!(content.contains("Loading items"));
2060    }
2061
2062    #[test]
2063    fn test_items_tab_is_first_and_hides_summary() {
2064        assert_eq!(DetailTab::ALL[0], DetailTab::Items);
2065
2066        let mut app = app_with_table();
2067        app.dynamodb_state.current_table = Some("orders".to_string());
2068        app.dynamodb_state.detail_tab = DetailTab::Items;
2069        let content = render_to_string(&app, 200, 40);
2070        // Ribbon stays, Summary pane does not.
2071        assert!(content.contains("Items"));
2072        assert!(content.contains("Settings"));
2073        assert!(!content.contains("Summary"));
2074
2075        // Another tab still shows the Summary pane.
2076        app.dynamodb_state.detail_tab = DetailTab::Settings;
2077        let content = render_to_string(&app, 200, 40);
2078        assert!(content.contains("Summary"));
2079    }
2080
2081    #[test]
2082    fn test_items_filter_row_controls_and_defaults() {
2083        let mut app = app_with_table();
2084        app.dynamodb_state.current_table = Some("orders".to_string());
2085        app.dynamodb_state.detail_tab = DetailTab::Items;
2086        let content = render_to_string(&app, 200, 40);
2087        // Scan is the default operation; the table is the default index.
2088        assert!(content.contains("Scan"));
2089        assert!(content.contains("[Table] orders"));
2090        assert!(content.contains("All attributes"));
2091
2092        // Focus order: operation, index, projection, filter input, pagination.
2093        assert_eq!(
2094            ITEMS_FILTER_CONTROLS,
2095            [
2096                ITEM_OPERATION,
2097                ITEM_INDEX,
2098                ITEM_PROJECTION,
2099                InputFocus::Filter,
2100                InputFocus::Pagination,
2101            ]
2102        );
2103        assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Scan);
2104        assert_eq!(
2105            app.dynamodb_state.item_projection,
2106            ItemProjection::AllAttributes
2107        );
2108    }
2109
2110    #[test]
2111    fn test_open_dropdown_lists_every_option() {
2112        let mut app = app_with_table();
2113        app.dynamodb_state.current_table = Some("orders".to_string());
2114        app.dynamodb_state.detail_tab = DetailTab::Items;
2115
2116        // Operation dropdown shows both Scan and Query.
2117        app.dynamodb_state.input_focus = ITEM_OPERATION;
2118        let content = render_to_string(&app, 200, 40);
2119        assert!(content.contains("Scan"));
2120        assert!(content.contains("Query"));
2121
2122        // Index dropdown shows the table and every secondary index.
2123        app.dynamodb_state.input_focus = ITEM_INDEX;
2124        let content = render_to_string(&app, 200, 40);
2125        assert!(content.contains("[Table] orders"));
2126        assert!(content.contains("customer-index"));
2127
2128        // Projection dropdown shows both projections.
2129        app.dynamodb_state.input_focus = ITEM_PROJECTION;
2130        let content = render_to_string(&app, 200, 40);
2131        assert!(content.contains("All attributes"));
2132        assert!(content.contains("Keys only"));
2133    }
2134
2135    #[test]
2136    fn test_dropdowns_cycle_and_schedule_a_reload() {
2137        let mut app = app_with_table();
2138        app.dynamodb_state.current_table = Some("orders".to_string());
2139        app.dynamodb_state.detail_tab = DetailTab::Items;
2140
2141        app.dynamodb_state.input_focus = ITEM_OPERATION;
2142        crate::dynamodb::actions::next_dropdown_value(&mut app);
2143        assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Query);
2144        // Query without a key value has nothing to run yet.
2145        assert!(!app.dynamodb_state.items_loading);
2146        crate::dynamodb::actions::prev_dropdown_value(&mut app);
2147        assert_eq!(app.dynamodb_state.item_operation, ItemOperation::Scan);
2148        assert!(app.dynamodb_state.items_loading);
2149
2150        // The index dropdown wraps across the table plus its one index.
2151        app.dynamodb_state.input_focus = ITEM_INDEX;
2152        crate::dynamodb::actions::next_dropdown_value(&mut app);
2153        assert_eq!(app.dynamodb_state.item_index, 1);
2154        crate::dynamodb::actions::next_dropdown_value(&mut app);
2155        assert_eq!(app.dynamodb_state.item_index, 0);
2156
2157        app.dynamodb_state.input_focus = ITEM_PROJECTION;
2158        crate::dynamodb::actions::next_dropdown_value(&mut app);
2159        assert_eq!(app.dynamodb_state.item_projection, ItemProjection::KeysOnly);
2160    }
2161
2162    #[test]
2163    fn test_items_render_dynamic_attribute_columns() {
2164        let mut app = app_with_table();
2165        app.dynamodb_state.current_table = Some("orders".to_string());
2166        app.dynamodb_state.detail_tab = DetailTab::Items;
2167        let mut item = DdbItem::default();
2168        item.attributes
2169            .insert("order_id".to_string(), "o1".to_string());
2170        item.attributes
2171            .insert("created_at".to_string(), "1".to_string());
2172        item.attributes
2173            .insert("status".to_string(), "SHIPPED".to_string());
2174        app.dynamodb_state.items.items = vec![item];
2175
2176        let content = render_to_string(&app, 200, 40);
2177        // The pane title is the table name on its own.
2178        assert!(content.contains("─ orders ─"));
2179        assert!(!content.contains("Items returned"));
2180        assert!(content.contains("order_id"));
2181        assert!(content.contains("SHIPPED"));
2182
2183        // Client-side filtering applies to Scan results and uses the Items tab's
2184        // own filter, not the table-list filter.
2185        app.dynamodb_state.tables.filter = "nomatch".to_string();
2186        assert_eq!(filtered_items(&app).len(), 1);
2187        app.dynamodb_state.item_filter = "shipped".to_string();
2188        assert_eq!(filtered_items(&app).len(), 1);
2189        app.dynamodb_state.item_filter = "nomatch".to_string();
2190        assert_eq!(filtered_items(&app).len(), 0);
2191    }
2192
2193    #[test]
2194    fn test_item_column_names_are_keys_first_then_sorted() {
2195        let mut app = app_with_table();
2196        app.dynamodb_state.current_table = Some("orders".to_string());
2197        app.dynamodb_state.detail_tab = DetailTab::Items;
2198        app.dynamodb_state.items.items = vec![
2199            item_with(&[("status", "SHIPPED"), ("order_id", "o1")]),
2200            item_with(&[("amount", "12"), ("created_at", "1"), ("order_id", "o2")]),
2201        ];
2202
2203        let t = current_table(&app).unwrap();
2204        // Partition key, then sort key, then the rest alphabetically — and the
2205        // set spans every loaded item, not just the visible page.
2206        assert_eq!(
2207            item_column_names(t, &app),
2208            vec!["order_id", "created_at", "amount", "status"]
2209        );
2210    }
2211
2212    #[test]
2213    fn test_items_preferences_lists_the_tables_own_columns() {
2214        let mut app = app_with_table();
2215        app.dynamodb_state.current_table = Some("orders".to_string());
2216        app.dynamodb_state.detail_tab = DetailTab::Items;
2217        app.dynamodb_state.items.items = vec![item_with(&[
2218            ("order_id", "o1"),
2219            ("created_at", "1"),
2220            ("status", "SHIPPED"),
2221        ])];
2222
2223        // Four attributes: the two keys plus status (created_at is the sort key).
2224        assert_eq!(crate::dynamodb::actions::column_count(&app), 3);
2225        assert_eq!(crate::dynamodb::actions::column_selector_max(&app), 9);
2226
2227        app.mode = crate::keymap::Mode::ColumnSelector;
2228        let content = render_to_string(&app, 200, 40);
2229        assert!(content.contains("Preferences"));
2230        assert!(content.contains("status"));
2231        assert!(content.contains("Page size"));
2232        // The Tables list columns must not leak into the Items preferences.
2233        assert!(!content.contains("Replication Regions"));
2234        assert!(!content.contains("Table class"));
2235    }
2236
2237    #[test]
2238    fn test_toggling_an_item_column_hides_it_but_keeps_one_visible() {
2239        use crate::dynamodb::actions::toggle_column;
2240        let mut app = app_with_table();
2241        app.dynamodb_state.current_table = Some("orders".to_string());
2242        app.dynamodb_state.detail_tab = DetailTab::Items;
2243        app.dynamodb_state.items.items = vec![item_with(&[
2244            ("order_id", "o1"),
2245            ("created_at", "1"),
2246            ("status", "SHIPPED"),
2247        ])];
2248        let names = |app: &App| item_visible_column_names(current_table(app).unwrap(), app);
2249        assert_eq!(names(&app), vec!["order_id", "created_at", "status"]);
2250
2251        // Index 0 is the "Columns" header; columns start at 1.
2252        app.column_selector_index = 3;
2253        toggle_column(&mut app);
2254        assert_eq!(app.dynamodb_state.item_hidden_columns, vec!["status"]);
2255        assert_eq!(names(&app), vec!["order_id", "created_at"]);
2256        let content = render_to_string(&app, 200, 40);
2257        assert!(!content.contains("SHIPPED"));
2258
2259        // Toggling again brings it back.
2260        toggle_column(&mut app);
2261        assert!(app.dynamodb_state.item_hidden_columns.is_empty());
2262        assert_eq!(names(&app), vec!["order_id", "created_at", "status"]);
2263
2264        // The last visible column cannot be hidden.
2265        for idx in [1, 2, 3] {
2266            app.column_selector_index = idx;
2267            toggle_column(&mut app);
2268        }
2269        assert_eq!(app.dynamodb_state.item_hidden_columns.len(), 2);
2270        assert_eq!(names(&app).len(), 1);
2271    }
2272
2273    #[test]
2274    fn test_items_preferences_page_size_is_actionable() {
2275        use crate::common::PageSize;
2276        use crate::dynamodb::actions::toggle_column;
2277        let mut app = app_with_table();
2278        app.dynamodb_state.current_table = Some("orders".to_string());
2279        app.dynamodb_state.detail_tab = DetailTab::Items;
2280        app.dynamodb_state.items.items = vec![item_with(&[("order_id", "o1")])];
2281
2282        // 2 columns (partition + sort key) -> page size radios at 5..=8.
2283        assert_eq!(crate::dynamodb::actions::column_count(&app), 2);
2284        app.column_selector_index = 7;
2285        toggle_column(&mut app);
2286        assert_eq!(app.dynamodb_state.items.page_size, PageSize::Fifty);
2287
2288        app.column_selector_index = 5;
2289        toggle_column(&mut app);
2290        assert_eq!(app.dynamodb_state.items.page_size, PageSize::Ten);
2291    }
2292
2293    #[test]
2294    fn test_query_needs_a_partition_key_value() {
2295        let mut app = app_with_table();
2296        app.dynamodb_state.current_table = Some("orders".to_string());
2297        app.dynamodb_state.detail_tab = DetailTab::Items;
2298        app.dynamodb_state.item_operation = ItemOperation::Query;
2299
2300        let content = render_to_string(&app, 200, 40);
2301        assert!(content.contains("─ orders ─"));
2302        assert!(content.contains("to run the query"));
2303        assert!(!needs_items_load(&app));
2304
2305        // Typing a value and leaving the filter input runs the query once.
2306        app.dynamodb_state.item_filter = "o1".to_string();
2307        assert!(needs_items_load(&app));
2308        app.dynamodb_state.executed_query = "o1".to_string();
2309        assert!(!needs_items_load(&app));
2310
2311        // While still typing (filter input mode) the query does not re-run.
2312        app.dynamodb_state.item_filter = "o2".to_string();
2313        app.mode = crate::keymap::Mode::FilterInput;
2314        assert!(!needs_items_load(&app));
2315    }
2316
2317    #[test]
2318    fn test_key_attribute_helpers() {
2319        assert_eq!(key_attribute_name("order_id (S)"), "order_id");
2320        assert_eq!(key_attribute_name("order_id"), "order_id");
2321        assert_eq!(key_attribute_type("created_at (N)"), "N");
2322        assert_eq!(key_attribute_type("order_id"), "S");
2323
2324        let t = &app_with_table().dynamodb_state.tables.items[0].clone();
2325        assert_eq!(
2326            selected_key_attributes(t, 0),
2327            vec!["order_id".to_string(), "created_at".to_string()]
2328        );
2329        assert_eq!(
2330            selected_key_attributes(t, 1),
2331            vec!["customer_id".to_string()]
2332        );
2333        assert_eq!(selected_item_index(t, 0), None);
2334        assert_eq!(selected_item_index(t, 1).as_deref(), Some("customer-index"));
2335
2336        // The query key type follows the selected table or index schema.
2337        assert_eq!(key_attribute_type(&selected_partition_key_label(t, 0)), "S");
2338        assert_eq!(key_attribute_type(&selected_partition_key_label(t, 1)), "S");
2339        let mut numeric = t.clone();
2340        numeric.partition_key = "seq (N)".to_string();
2341        assert_eq!(
2342            key_attribute_type(&selected_partition_key_label(&numeric, 0)),
2343            "N"
2344        );
2345    }
2346
2347    #[test]
2348    fn test_tab_keys_reach_items_and_flag_a_load() {
2349        use crate::keymap::Action;
2350        let mut app = app_with_table();
2351        crate::dynamodb::actions::select_item(&mut app);
2352        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
2353
2354        // Tab moves forward off Items.
2355        app.handle_action(Action::NextDetailTab);
2356        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Settings);
2357
2358        // BackTab from Settings lands on Items and schedules the scan.
2359        app.dynamodb_state.items_loading = false;
2360        app.handle_action(Action::PrevDetailTab);
2361        assert_eq!(app.dynamodb_state.detail_tab, DetailTab::Items);
2362        assert!(app.dynamodb_state.items_loading);
2363        assert!(needs_items_load(&app));
2364    }
2365
2366    #[test]
2367    fn test_breadcrumb() {
2368        let mut app = app_with_table();
2369        assert_eq!(
2370            crate::dynamodb::actions::breadcrumb(&app),
2371            vec!["DynamoDB".to_string(), "Tables".to_string()]
2372        );
2373        app.dynamodb_state.current_table = Some("orders".to_string());
2374        assert_eq!(
2375            crate::dynamodb::actions::breadcrumb(&app),
2376            vec!["DynamoDB".to_string(), "orders".to_string()]
2377        );
2378    }
2379}