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(), 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}