Skip to main content

rusticity_term/dynamodb/
table.rs

1use crate::common::{format_bytes, translate_column, ColumnId};
2use crate::ui::table::Column as TableColumn;
3use ratatui::prelude::*;
4use rusticity_core::dynamodb::DdbTable as Table;
5use std::collections::HashMap;
6
7pub fn init(i18n: &mut HashMap<String, String>) {
8    for col in Column::all() {
9        i18n.entry(col.id().to_string())
10            .or_insert_with(|| col.default_name().to_string());
11    }
12}
13
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum Column {
16    Name,
17    Status,
18    PartitionKey,
19    SortKey,
20    Indexes,
21    ReplicationRegions,
22    DeletionProtection,
23    Favorite,
24    ReadCapacityMode,
25    WriteCapacityMode,
26    TotalSize,
27    TableClass,
28}
29
30impl Column {
31    const ID_NAME: &'static str = "column.dynamodb.table.name";
32    const ID_STATUS: &'static str = "column.dynamodb.table.status";
33    const ID_PARTITION_KEY: &'static str = "column.dynamodb.table.partition_key";
34    const ID_SORT_KEY: &'static str = "column.dynamodb.table.sort_key";
35    const ID_INDEXES: &'static str = "column.dynamodb.table.indexes";
36    const ID_REPLICATION_REGIONS: &'static str = "column.dynamodb.table.replication_regions";
37    const ID_DELETION_PROTECTION: &'static str = "column.dynamodb.table.deletion_protection";
38    const ID_FAVORITE: &'static str = "column.dynamodb.table.favorite";
39    const ID_READ_CAPACITY_MODE: &'static str = "column.dynamodb.table.read_capacity_mode";
40    const ID_WRITE_CAPACITY_MODE: &'static str = "column.dynamodb.table.write_capacity_mode";
41    const ID_TOTAL_SIZE: &'static str = "column.dynamodb.table.total_size";
42    const ID_TABLE_CLASS: &'static str = "column.dynamodb.table.table_class";
43
44    pub const fn id(&self) -> &'static str {
45        match self {
46            Column::Name => Self::ID_NAME,
47            Column::Status => Self::ID_STATUS,
48            Column::PartitionKey => Self::ID_PARTITION_KEY,
49            Column::SortKey => Self::ID_SORT_KEY,
50            Column::Indexes => Self::ID_INDEXES,
51            Column::ReplicationRegions => Self::ID_REPLICATION_REGIONS,
52            Column::DeletionProtection => Self::ID_DELETION_PROTECTION,
53            Column::Favorite => Self::ID_FAVORITE,
54            Column::ReadCapacityMode => Self::ID_READ_CAPACITY_MODE,
55            Column::WriteCapacityMode => Self::ID_WRITE_CAPACITY_MODE,
56            Column::TotalSize => Self::ID_TOTAL_SIZE,
57            Column::TableClass => Self::ID_TABLE_CLASS,
58        }
59    }
60
61    pub const fn default_name(&self) -> &'static str {
62        match self {
63            Column::Name => "Name",
64            Column::Status => "Status",
65            Column::PartitionKey => "Partition key",
66            Column::SortKey => "Sort key",
67            Column::Indexes => "Indexes",
68            Column::ReplicationRegions => "Replication Regions",
69            Column::DeletionProtection => "Deletion protection",
70            Column::Favorite => "Favorite",
71            Column::ReadCapacityMode => "Read capacity mode",
72            Column::WriteCapacityMode => "Write capacity mode",
73            Column::TotalSize => "Total size",
74            Column::TableClass => "Table class",
75        }
76    }
77
78    pub const fn all() -> [Column; 12] {
79        [
80            Column::Name,
81            Column::Status,
82            Column::PartitionKey,
83            Column::SortKey,
84            Column::Indexes,
85            Column::ReplicationRegions,
86            Column::DeletionProtection,
87            Column::Favorite,
88            Column::ReadCapacityMode,
89            Column::WriteCapacityMode,
90            Column::TotalSize,
91            Column::TableClass,
92        ]
93    }
94
95    pub fn ids() -> Vec<ColumnId> {
96        Self::all().iter().map(|c| c.id()).collect()
97    }
98
99    /// Every column is visible by default, matching the DynamoDB console.
100    pub fn default_visible_ids() -> Vec<ColumnId> {
101        Self::ids()
102    }
103
104    pub fn from_id(id: &str) -> Option<Self> {
105        match id {
106            Self::ID_NAME => Some(Column::Name),
107            Self::ID_STATUS => Some(Column::Status),
108            Self::ID_PARTITION_KEY => Some(Column::PartitionKey),
109            Self::ID_SORT_KEY => Some(Column::SortKey),
110            Self::ID_INDEXES => Some(Column::Indexes),
111            Self::ID_REPLICATION_REGIONS => Some(Column::ReplicationRegions),
112            Self::ID_DELETION_PROTECTION => Some(Column::DeletionProtection),
113            Self::ID_FAVORITE => Some(Column::Favorite),
114            Self::ID_READ_CAPACITY_MODE => Some(Column::ReadCapacityMode),
115            Self::ID_WRITE_CAPACITY_MODE => Some(Column::WriteCapacityMode),
116            Self::ID_TOTAL_SIZE => Some(Column::TotalSize),
117            Self::ID_TABLE_CLASS => Some(Column::TableClass),
118            _ => None,
119        }
120    }
121
122    pub fn name(&self) -> String {
123        translate_column(self.id(), self.default_name())
124    }
125}
126
127impl TableColumn<Table> for Column {
128    fn name(&self) -> &str {
129        Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
130    }
131
132    fn width(&self) -> u16 {
133        let translated = translate_column(self.id(), self.default_name());
134        translated.len().max(match self {
135            Column::Name => 30,
136            Column::Status => 12,
137            Column::PartitionKey => 20,
138            Column::SortKey => 20,
139            Column::Indexes => 8,
140            Column::ReplicationRegions => 20,
141            Column::DeletionProtection => 19,
142            Column::Favorite => 8,
143            Column::ReadCapacityMode => 18,
144            Column::WriteCapacityMode => 19,
145            Column::TotalSize => 12,
146            Column::TableClass => 12,
147        }) as u16
148    }
149
150    fn render(&self, item: &Table) -> (String, Style) {
151        match self {
152            Column::Name => (item.name.clone(), Style::default()),
153            Column::Status => {
154                let (text, color) = format_status(&item.status);
155                (text.to_string(), Style::default().fg(color))
156            }
157            Column::PartitionKey => (item.partition_key.clone(), Style::default()),
158            Column::SortKey => (dash_if_empty(&item.sort_key), Style::default()),
159            Column::Indexes => (item.index_count().to_string(), Style::default()),
160            Column::ReplicationRegions => {
161                (dash_if_empty(&item.replication_regions()), Style::default())
162            }
163            Column::DeletionProtection => (
164                item.deletion_protection.clone(),
165                Style::default().fg(crate::ui::dynamodb::enabled_disabled_color(
166                    &item.deletion_protection,
167                )),
168            ),
169            // Favorites are not persisted yet — always render the empty star.
170            Column::Favorite => (
171                "☆".to_string(),
172                Style::default().fg(ratatui::style::Color::DarkGray),
173            ),
174            Column::ReadCapacityMode => (item.read_capacity_mode.clone(), Style::default()),
175            Column::WriteCapacityMode => (item.write_capacity_mode.clone(), Style::default()),
176            Column::TotalSize => (format_bytes(item.total_size_bytes), Style::default()),
177            Column::TableClass => (item.table_class.clone(), Style::default()),
178        }
179    }
180}
181
182/// Show "-" instead of an empty cell (tables without a sort key).
183fn dash_if_empty(value: &str) -> String {
184    if value.is_empty() {
185        "-".to_string()
186    } else {
187        value.to_string()
188    }
189}
190
191pub fn format_status(status: &str) -> (&'static str, Color) {
192    match status.to_uppercase().as_str() {
193        "ACTIVE" => ("✅ Active", Color::Green),
194        "CREATING" => ("⏳ Creating", Color::Yellow),
195        "UPDATING" => ("🔄 Updating", Color::Yellow),
196        "DELETING" => ("🗑 Deleting", Color::Red),
197        "ARCHIVING" => ("📦 Archiving", Color::Yellow),
198        "ARCHIVED" => ("📦 Archived", Color::DarkGray),
199        "INACCESSIBLE_ENCRYPTION_CREDENTIALS" => ("⚠ Inaccessible", Color::Red),
200        _ => ("Unknown", Color::White),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_column_ids_have_prefix() {
210        for col in Column::all() {
211            assert!(col.id().starts_with("column.dynamodb.table."));
212        }
213    }
214
215    #[test]
216    fn test_from_id_roundtrip() {
217        for col in Column::all() {
218            assert_eq!(Column::from_id(col.id()), Some(col));
219        }
220    }
221
222    #[test]
223    fn test_all_twelve_columns() {
224        assert_eq!(Column::all().len(), 12);
225    }
226
227    #[test]
228    fn test_column_order_matches_console() {
229        assert_eq!(
230            Column::all()
231                .iter()
232                .map(|c| c.default_name())
233                .collect::<Vec<_>>(),
234            vec![
235                "Name",
236                "Status",
237                "Partition key",
238                "Sort key",
239                "Indexes",
240                "Replication Regions",
241                "Deletion protection",
242                "Favorite",
243                "Read capacity mode",
244                "Write capacity mode",
245                "Total size",
246                "Table class",
247            ]
248        );
249    }
250
251    #[test]
252    fn test_every_column_visible_by_default() {
253        assert_eq!(Column::default_visible_ids(), Column::ids());
254        assert_eq!(Column::default_visible_ids().len(), 12);
255    }
256
257    #[test]
258    fn test_render_indexes_sort_key_and_replication() {
259        use rusticity_core::dynamodb::{DdbIndex, DdbReplica};
260        let table = Table {
261            name: "orders".to_string(),
262            status: "ACTIVE".to_string(),
263            partition_key: "pk (S)".to_string(),
264            sort_key: String::new(),
265            global_secondary_indexes: vec![DdbIndex::default(), DdbIndex::default()],
266            total_size_bytes: 2048,
267            ..Default::default()
268        };
269
270        let (indexes, _) = TableColumn::render(&Column::Indexes, &table);
271        assert_eq!(indexes, "2");
272        // Missing sort keys and non-replicated tables render as "-".
273        let (sort, _) = TableColumn::render(&Column::SortKey, &table);
274        assert_eq!(sort, "-");
275        let (regions, _) = TableColumn::render(&Column::ReplicationRegions, &table);
276        assert_eq!(regions, "-");
277        let (status, style) = TableColumn::render(&Column::Status, &table);
278        assert_eq!(status, "✅ Active");
279        assert_eq!(style.fg, Some(Color::Green));
280
281        let replicated = Table {
282            replicas: vec![
283                DdbReplica {
284                    region: "us-east-1".to_string(),
285                    ..Default::default()
286                },
287                DdbReplica {
288                    region: "eu-west-1".to_string(),
289                    ..Default::default()
290                },
291            ],
292            ..table
293        };
294        let (regions, _) = TableColumn::render(&Column::ReplicationRegions, &replicated);
295        assert_eq!(regions, "us-east-1, eu-west-1");
296    }
297
298    #[test]
299    fn test_format_status() {
300        assert_eq!(format_status("ACTIVE").1, Color::Green);
301        assert_eq!(format_status("CREATING").1, Color::Yellow);
302        assert_eq!(format_status("DELETING").1, Color::Red);
303        assert_eq!(format_status("whatever").0, "Unknown");
304    }
305}