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