Skip to main content

rusticity_term/s3/
mod.rs

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