Skip to main content

rusticity_term/s3/
mod.rs

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