Skip to main content

rusticity_term/
s3.rs

1use crate::common::translate_column;
2use crate::common::{format_bytes, ColumnId, UTC_TIMESTAMP_WIDTH};
3use crate::ui::table::Column as TableColumn;
4use ratatui::prelude::*;
5use std::collections::HashMap;
6
7pub fn init(i18n: &mut HashMap<String, String>) {
8    for col in [
9        BucketColumn::Name,
10        BucketColumn::Region,
11        BucketColumn::CreationDate,
12    ] {
13        i18n.entry(col.id().to_string())
14            .or_insert_with(|| col.default_name().to_string());
15    }
16
17    for col in [
18        ObjectColumn::Key,
19        ObjectColumn::Size,
20        ObjectColumn::LastModified,
21        ObjectColumn::StorageClass,
22    ] {
23        i18n.entry(col.id().to_string())
24            .or_insert_with(|| col.default_name().to_string());
25    }
26}
27
28pub fn console_url_buckets(region: &str) -> String {
29    format!(
30        "https://{}.console.aws.amazon.com/s3/buckets?region={}",
31        region, region
32    )
33}
34
35pub fn console_url_bucket(region: &str, bucket: &str, prefix: &str) -> String {
36    if prefix.is_empty() {
37        format!(
38            "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}",
39            bucket, region
40        )
41    } else {
42        format!(
43            "https://s3.console.aws.amazon.com/s3/buckets/{}?region={}&prefix={}",
44            bucket,
45            region,
46            urlencoding::encode(prefix)
47        )
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct Bucket {
53    pub name: String,
54    pub region: String,
55    pub creation_date: String,
56}
57
58#[derive(Debug, Clone)]
59pub struct Object {
60    pub key: String,
61    pub size: i64,
62    pub last_modified: String,
63    pub is_prefix: bool,
64    pub storage_class: String,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum BucketColumn {
69    Name,
70    Region,
71    CreationDate,
72}
73
74impl BucketColumn {
75    const ID_NAME: &'static str = "column.s3.bucket.name";
76    const ID_REGION: &'static str = "column.s3.bucket.region";
77    const ID_CREATION_DATE: &'static str = "column.s3.bucket.creation_date";
78
79    pub const fn id(&self) -> &'static str {
80        match self {
81            BucketColumn::Name => Self::ID_NAME,
82            BucketColumn::Region => Self::ID_REGION,
83            BucketColumn::CreationDate => Self::ID_CREATION_DATE,
84        }
85    }
86
87    pub const fn default_name(&self) -> &'static str {
88        match self {
89            BucketColumn::Name => "Name",
90            BucketColumn::Region => "Region",
91            BucketColumn::CreationDate => "Creation date",
92        }
93    }
94
95    pub const fn all() -> [BucketColumn; 3] {
96        [
97            BucketColumn::Name,
98            BucketColumn::Region,
99            BucketColumn::CreationDate,
100        ]
101    }
102
103    pub fn ids() -> Vec<ColumnId> {
104        Self::all().iter().map(|c| c.id()).collect()
105    }
106
107    pub fn from_id(id: &str) -> Option<Self> {
108        match id {
109            Self::ID_NAME => Some(BucketColumn::Name),
110            Self::ID_REGION => Some(BucketColumn::Region),
111            Self::ID_CREATION_DATE => Some(BucketColumn::CreationDate),
112            _ => None,
113        }
114    }
115
116    pub fn name(&self) -> String {
117        translate_column(self.id(), self.default_name())
118    }
119}
120
121impl TableColumn<Bucket> for BucketColumn {
122    fn name(&self) -> &str {
123        Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
124    }
125
126    fn width(&self) -> u16 {
127        let translated = translate_column(self.id(), self.default_name());
128        translated.len().max(match self {
129            BucketColumn::Name => 30,
130            BucketColumn::Region => 15,
131            BucketColumn::CreationDate => UTC_TIMESTAMP_WIDTH as usize,
132        }) as u16
133    }
134
135    fn render(&self, item: &Bucket) -> (String, Style) {
136        let text = match self {
137            BucketColumn::Name => item.name.clone(),
138            BucketColumn::Region => item.region.clone(),
139            BucketColumn::CreationDate => item.creation_date.clone(),
140        };
141        (text, Style::default())
142    }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub enum ObjectColumn {
147    Key,
148    Type,
149    LastModified,
150    Size,
151    StorageClass,
152}
153
154impl ObjectColumn {
155    const ID_KEY: &'static str = "column.s3.object.key";
156    const ID_TYPE: &'static str = "column.s3.object.type";
157    const ID_LAST_MODIFIED: &'static str = "column.s3.object.last_modified";
158    const ID_SIZE: &'static str = "column.s3.object.size";
159    const ID_STORAGE_CLASS: &'static str = "column.s3.object.storage_class";
160
161    pub const fn id(&self) -> &'static str {
162        match self {
163            ObjectColumn::Key => Self::ID_KEY,
164            ObjectColumn::Type => Self::ID_TYPE,
165            ObjectColumn::LastModified => Self::ID_LAST_MODIFIED,
166            ObjectColumn::Size => Self::ID_SIZE,
167            ObjectColumn::StorageClass => Self::ID_STORAGE_CLASS,
168        }
169    }
170
171    pub const fn default_name(&self) -> &'static str {
172        match self {
173            ObjectColumn::Key => "Name",
174            ObjectColumn::Type => "Type",
175            ObjectColumn::LastModified => "Last modified",
176            ObjectColumn::Size => "Size",
177            ObjectColumn::StorageClass => "Storage class",
178        }
179    }
180
181    pub const fn all() -> [ObjectColumn; 5] {
182        [
183            ObjectColumn::Key,
184            ObjectColumn::Type,
185            ObjectColumn::LastModified,
186            ObjectColumn::Size,
187            ObjectColumn::StorageClass,
188        ]
189    }
190
191    pub fn from_id(id: &str) -> Option<Self> {
192        match id {
193            Self::ID_KEY => Some(ObjectColumn::Key),
194            Self::ID_TYPE => Some(ObjectColumn::Type),
195            Self::ID_LAST_MODIFIED => Some(ObjectColumn::LastModified),
196            Self::ID_SIZE => Some(ObjectColumn::Size),
197            Self::ID_STORAGE_CLASS => Some(ObjectColumn::StorageClass),
198            _ => None,
199        }
200    }
201
202    pub fn name(&self) -> String {
203        translate_column(self.id(), self.default_name())
204    }
205}
206
207impl TableColumn<Object> for ObjectColumn {
208    fn name(&self) -> &str {
209        Box::leak(translate_column(self.id(), self.default_name()).into_boxed_str())
210    }
211
212    fn width(&self) -> u16 {
213        let translated = translate_column(self.id(), self.default_name());
214        translated.len().max(match self {
215            ObjectColumn::Key => 40,
216            ObjectColumn::Type => 10,
217            ObjectColumn::LastModified => UTC_TIMESTAMP_WIDTH as usize,
218            ObjectColumn::Size => 15,
219            ObjectColumn::StorageClass => 15,
220        }) as u16
221    }
222
223    fn render(&self, item: &Object) -> (String, Style) {
224        let text = match self {
225            ObjectColumn::Key => {
226                let icon = if item.is_prefix { "📁" } else { "📄" };
227                format!("{} {}", icon, item.key)
228            }
229            ObjectColumn::Type => {
230                if item.is_prefix {
231                    "Folder".to_string()
232                } else {
233                    "File".to_string()
234                }
235            }
236            ObjectColumn::LastModified => {
237                if item.last_modified.is_empty() {
238                    String::new()
239                } else {
240                    format!(
241                        "{} (UTC)",
242                        item.last_modified
243                            .split('T')
244                            .next()
245                            .unwrap_or(&item.last_modified)
246                    )
247                }
248            }
249            ObjectColumn::Size => {
250                if item.is_prefix {
251                    String::new()
252                } else {
253                    format_bytes(item.size)
254                }
255            }
256            ObjectColumn::StorageClass => {
257                if item.storage_class.is_empty() {
258                    String::new()
259                } else {
260                    item.storage_class
261                        .chars()
262                        .next()
263                        .unwrap()
264                        .to_uppercase()
265                        .to_string()
266                        + &item.storage_class[1..].to_lowercase()
267                }
268            }
269        };
270        (text, Style::default())
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_bucket_column_ids_have_correct_prefix() {
280        for col in BucketColumn::all() {
281            assert!(
282                col.id().starts_with("column.s3.bucket."),
283                "BucketColumn ID '{}' should start with 'column.s3.bucket.'",
284                col.id()
285            );
286        }
287    }
288
289    #[test]
290    fn test_object_column_ids_have_correct_prefix() {
291        for col in ObjectColumn::all() {
292            assert!(
293                col.id().starts_with("column.s3.object."),
294                "ObjectColumn ID '{}' should start with 'column.s3.object.'",
295                col.id()
296            );
297        }
298    }
299}