1use crate::common::{translate_column, ColumnId, UTC_TIMESTAMP_WIDTH};
2use crate::ui::cfn::DetailTab;
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 Column::Name,
10 Column::StackId,
11 Column::Status,
12 Column::CreatedTime,
13 Column::UpdatedTime,
14 Column::DeletedTime,
15 Column::DriftStatus,
16 Column::LastDriftCheckTime,
17 Column::StatusReason,
18 Column::Description,
19 ] {
20 i18n.entry(col.id().to_string())
21 .or_insert_with(|| col.default_name().to_string());
22 }
23 for col in crate::ui::cfn::ParameterColumn::all() {
24 i18n.entry(col.id().to_string())
25 .or_insert_with(|| col.default_name().to_string());
26 }
27 for col in crate::ui::cfn::OutputColumn::all() {
28 i18n.entry(col.id().to_string())
29 .or_insert_with(|| col.default_name().to_string());
30 }
31 for col in crate::ui::cfn::ResourceColumn::all() {
32 i18n.entry(col.id().to_string())
33 .or_insert_with(|| col.default_name().to_string());
34 }
35}
36
37pub fn console_url_stacks(region: &str) -> String {
38 format!(
39 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks",
40 region, region
41 )
42}
43
44pub fn console_url_stack_detail(region: &str, stack_name: &str, stack_id: &str) -> String {
45 format!(
46 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks/{}?filteringText=&filteringStatus=active&viewNested=true&stackId={}",
47 region, region, stack_name, stack_id
48 )
49}
50
51pub fn console_url_stack_detail_with_tab(region: &str, stack_id: &str, tab: &DetailTab) -> String {
52 let tab_path = match tab {
53 DetailTab::StackInfo => "stackinfo",
54 DetailTab::Events => "events",
55 DetailTab::Resources => "resources",
56 DetailTab::Outputs => "outputs",
57 DetailTab::Parameters => "parameters",
58 DetailTab::Template => "template",
59 DetailTab::ChangeSets => "changesets",
60 DetailTab::GitSync => "gitsync",
61 };
62 let encoded_arn = urlencoding::encode(stack_id);
63 format!(
64 "https://{}.console.aws.amazon.com/cloudformation/home?region={}#/stacks/{}?filteringText=&filteringStatus=active&viewNested=true&stackId={}",
65 region, region, tab_path, encoded_arn
66 )
67}
68
69#[derive(Debug, Clone)]
70pub struct Stack {
71 pub name: String,
72 pub stack_id: String,
73 pub status: String,
74 pub created_time: String,
75 pub updated_time: String,
76 pub deleted_time: String,
77 pub drift_status: String,
78 pub last_drift_check_time: String,
79 pub status_reason: String,
80 pub description: String,
81 pub detailed_status: String,
82 pub root_stack: String,
83 pub parent_stack: String,
84 pub termination_protection: bool,
85 pub iam_role: String,
86 pub tags: Vec<(String, String)>,
87 pub stack_policy: String,
88 pub rollback_monitoring_time: String,
89 pub rollback_alarms: Vec<String>,
90 pub notification_arns: Vec<String>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub enum Column {
95 Name,
96 StackId,
97 Status,
98 CreatedTime,
99 UpdatedTime,
100 DeletedTime,
101 DriftStatus,
102 LastDriftCheckTime,
103 StatusReason,
104 Description,
105}
106
107impl Column {
108 const ID_NAME: &'static str = "column.cfn.stack.name";
109 const ID_STACK_ID: &'static str = "column.cfn.stack.stack_id";
110 const ID_STATUS: &'static str = "column.cfn.stack.status";
111 const ID_CREATED_TIME: &'static str = "column.cfn.stack.created_time";
112 const ID_UPDATED_TIME: &'static str = "column.cfn.stack.updated_time";
113 const ID_DELETED_TIME: &'static str = "column.cfn.stack.deleted_time";
114 const ID_DRIFT_STATUS: &'static str = "column.cfn.stack.drift_status";
115 const ID_LAST_DRIFT_CHECK_TIME: &'static str = "column.cfn.stack.last_drift_check_time";
116 const ID_STATUS_REASON: &'static str = "column.cfn.stack.status_reason";
117 const ID_DESCRIPTION: &'static str = "column.cfn.stack.description";
118
119 pub const fn id(&self) -> &'static str {
120 match self {
121 Column::Name => Self::ID_NAME,
122 Column::StackId => Self::ID_STACK_ID,
123 Column::Status => Self::ID_STATUS,
124 Column::CreatedTime => Self::ID_CREATED_TIME,
125 Column::UpdatedTime => Self::ID_UPDATED_TIME,
126 Column::DeletedTime => Self::ID_DELETED_TIME,
127 Column::DriftStatus => Self::ID_DRIFT_STATUS,
128 Column::LastDriftCheckTime => Self::ID_LAST_DRIFT_CHECK_TIME,
129 Column::StatusReason => Self::ID_STATUS_REASON,
130 Column::Description => Self::ID_DESCRIPTION,
131 }
132 }
133
134 pub const fn default_name(&self) -> &'static str {
135 match self {
136 Column::Name => "Stack Name",
137 Column::StackId => "Stack ID",
138 Column::Status => "Status",
139 Column::CreatedTime => "Created Time",
140 Column::UpdatedTime => "Updated Time",
141 Column::DeletedTime => "Deleted Time",
142 Column::DriftStatus => "Drift Status",
143 Column::LastDriftCheckTime => "Last Drift Check Time",
144 Column::StatusReason => "Status Reason",
145 Column::Description => "Description",
146 }
147 }
148
149 pub fn name(&self) -> String {
150 translate_column(self.id(), self.default_name())
151 }
152
153 pub fn from_id(id: &str) -> Option<Self> {
154 match id {
155 Self::ID_NAME => Some(Column::Name),
156 Self::ID_STACK_ID => Some(Column::StackId),
157 Self::ID_STATUS => Some(Column::Status),
158 Self::ID_CREATED_TIME => Some(Column::CreatedTime),
159 Self::ID_UPDATED_TIME => Some(Column::UpdatedTime),
160 Self::ID_DELETED_TIME => Some(Column::DeletedTime),
161 Self::ID_DRIFT_STATUS => Some(Column::DriftStatus),
162 Self::ID_LAST_DRIFT_CHECK_TIME => Some(Column::LastDriftCheckTime),
163 Self::ID_STATUS_REASON => Some(Column::StatusReason),
164 Self::ID_DESCRIPTION => Some(Column::Description),
165 _ => None,
166 }
167 }
168
169 pub fn all() -> [Column; 10] {
170 [
171 Column::Name,
172 Column::StackId,
173 Column::Status,
174 Column::CreatedTime,
175 Column::UpdatedTime,
176 Column::DeletedTime,
177 Column::DriftStatus,
178 Column::LastDriftCheckTime,
179 Column::StatusReason,
180 Column::Description,
181 ]
182 }
183
184 pub fn ids() -> Vec<ColumnId> {
185 Self::all().iter().map(|c| c.id()).collect()
186 }
187
188 pub fn to_column(&self) -> Box<dyn TableColumn<&Stack>> {
189 struct StackColumn {
190 variant: Column,
191 }
192
193 impl TableColumn<&Stack> for StackColumn {
194 fn name(&self) -> &str {
195 Box::leak(self.variant.name().into_boxed_str())
196 }
197
198 fn width(&self) -> u16 {
199 let translated = translate_column(self.variant.id(), self.variant.default_name());
200 translated.len().max(match self.variant {
201 Column::Name => 30,
202 Column::StackId => 20,
203 Column::Status => 35,
204 Column::CreatedTime
205 | Column::UpdatedTime
206 | Column::DeletedTime
207 | Column::LastDriftCheckTime => UTC_TIMESTAMP_WIDTH as usize,
208 Column::DriftStatus => 20,
209 Column::StatusReason | Column::Description => 50,
210 }) as u16
211 }
212
213 fn render(&self, item: &&Stack) -> (String, Style) {
214 match self.variant {
215 Column::Name => (item.name.clone(), Style::default()),
216 Column::StackId => (item.stack_id.clone(), Style::default()),
217 Column::Status => {
218 let (formatted, color) = format_status(&item.status);
219 (formatted, Style::default().fg(color))
220 }
221 Column::CreatedTime => (item.created_time.clone(), Style::default()),
222 Column::UpdatedTime => (item.updated_time.clone(), Style::default()),
223 Column::DeletedTime => (item.deleted_time.clone(), Style::default()),
224 Column::DriftStatus => (item.drift_status.clone(), Style::default()),
225 Column::LastDriftCheckTime => {
226 (item.last_drift_check_time.clone(), Style::default())
227 }
228 Column::StatusReason => (item.status_reason.clone(), Style::default()),
229 Column::Description => (item.description.clone(), Style::default()),
230 }
231 }
232 }
233
234 Box::new(StackColumn { variant: *self })
235 }
236}
237
238pub fn format_status(status: &str) -> (String, ratatui::style::Color) {
239 let (emoji, color) = match status {
240 "UPDATE_COMPLETE" | "CREATE_COMPLETE" | "DELETE_COMPLETE" | "IMPORT_COMPLETE" => {
241 ("✅ ", ratatui::style::Color::Green)
242 }
243 "ROLLBACK_COMPLETE"
244 | "UPDATE_ROLLBACK_COMPLETE"
245 | "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS"
246 | "UPDATE_FAILED"
247 | "CREATE_FAILED"
248 | "DELETE_FAILED"
249 | "ROLLBACK_FAILED"
250 | "UPDATE_ROLLBACK_FAILED"
251 | "IMPORT_ROLLBACK_FAILED"
252 | "IMPORT_ROLLBACK_COMPLETE" => ("❌ ", ratatui::style::Color::Red),
253 "UPDATE_IN_PROGRESS"
254 | "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS"
255 | "DELETE_IN_PROGRESS"
256 | "CREATE_IN_PROGRESS"
257 | "ROLLBACK_IN_PROGRESS"
258 | "UPDATE_ROLLBACK_IN_PROGRESS"
259 | "REVIEW_IN_PROGRESS"
260 | "IMPORT_IN_PROGRESS"
261 | "IMPORT_ROLLBACK_IN_PROGRESS" => ("ℹ️ ", ratatui::style::Color::Blue),
262 _ => ("", ratatui::style::Color::White),
263 };
264
265 (format!("{}{}", emoji, status), color)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::common::{CyclicEnum, SortDirection};
272 use crate::ui::cfn::{DetailTab, State, StatusFilter};
273
274 #[test]
275 fn test_state_default() {
276 let state = State::default();
277 assert_eq!(state.table.items.len(), 0);
278 assert_eq!(state.table.selected, 0);
279 assert!(!state.table.loading);
280 assert_eq!(state.table.filter, "");
281 assert_eq!(state.status_filter, StatusFilter::All);
282 assert!(!state.view_nested);
283 assert_eq!(state.table.expanded_item, None);
284 assert_eq!(state.current_stack, None);
285 assert_eq!(state.detail_tab, DetailTab::StackInfo);
286 assert_eq!(state.overview_scroll, 0);
287 assert_eq!(state.sort_column, Column::CreatedTime);
288 assert_eq!(state.sort_direction, SortDirection::Desc);
289 }
290
291 #[test]
292 fn test_status_filter_names() {
293 assert_eq!(StatusFilter::Active.name(), "Active");
294 assert_eq!(StatusFilter::Complete.name(), "Complete");
295 assert_eq!(StatusFilter::Failed.name(), "Failed");
296 assert_eq!(StatusFilter::Deleted.name(), "Deleted");
297 assert_eq!(StatusFilter::InProgress.name(), "In progress");
298 }
299
300 #[test]
301 fn test_status_filter_next() {
302 assert_eq!(StatusFilter::All.next(), StatusFilter::Active);
303 assert_eq!(StatusFilter::Active.next(), StatusFilter::Complete);
304 assert_eq!(StatusFilter::Complete.next(), StatusFilter::Failed);
305 assert_eq!(StatusFilter::Failed.next(), StatusFilter::Deleted);
306 assert_eq!(StatusFilter::Deleted.next(), StatusFilter::InProgress);
307 assert_eq!(StatusFilter::InProgress.next(), StatusFilter::All);
308 }
309
310 #[test]
311 fn test_status_filter_matches_active() {
312 let filter = StatusFilter::Active;
313 assert!(filter.matches("CREATE_IN_PROGRESS"));
314 assert!(filter.matches("UPDATE_IN_PROGRESS"));
315 assert!(!filter.matches("CREATE_COMPLETE"));
316 assert!(!filter.matches("DELETE_COMPLETE"));
317 assert!(!filter.matches("CREATE_FAILED"));
318 }
319
320 #[test]
321 fn test_status_filter_matches_complete() {
322 let filter = StatusFilter::Complete;
323 assert!(filter.matches("CREATE_COMPLETE"));
324 assert!(filter.matches("UPDATE_COMPLETE"));
325 assert!(!filter.matches("DELETE_COMPLETE"));
326 assert!(!filter.matches("CREATE_FAILED"));
327 assert!(!filter.matches("CREATE_IN_PROGRESS"));
328 }
329
330 #[test]
331 fn test_status_filter_matches_failed() {
332 let filter = StatusFilter::Failed;
333 assert!(filter.matches("CREATE_FAILED"));
334 assert!(filter.matches("UPDATE_FAILED"));
335 assert!(filter.matches("ROLLBACK_FAILED"));
336 assert!(!filter.matches("CREATE_COMPLETE"));
337 assert!(!filter.matches("DELETE_COMPLETE"));
338 }
339
340 #[test]
341 fn test_status_filter_matches_deleted() {
342 let filter = StatusFilter::Deleted;
343 assert!(filter.matches("DELETE_COMPLETE"));
344 assert!(filter.matches("DELETE_IN_PROGRESS"));
345 assert!(filter.matches("DELETE_FAILED"));
346 assert!(!filter.matches("CREATE_COMPLETE"));
347 assert!(!filter.matches("UPDATE_FAILED"));
348 }
349
350 #[test]
351 fn test_status_filter_matches_in_progress() {
352 let filter = StatusFilter::InProgress;
353 assert!(filter.matches("CREATE_IN_PROGRESS"));
354 assert!(filter.matches("UPDATE_IN_PROGRESS"));
355 assert!(filter.matches("DELETE_IN_PROGRESS"));
356 assert!(!filter.matches("CREATE_COMPLETE"));
357 assert!(!filter.matches("CREATE_FAILED"));
358 }
359
360 #[test]
361 fn test_detail_tab_names() {
362 assert_eq!(DetailTab::StackInfo.name(), "Stack info");
363 assert_eq!(DetailTab::Events.name(), "Events");
364 assert_eq!(DetailTab::Resources.name(), "Resources");
365 assert_eq!(DetailTab::Outputs.name(), "Outputs");
366 assert_eq!(DetailTab::Parameters.name(), "Parameters");
367 assert_eq!(DetailTab::Template.name(), "Template");
368 assert_eq!(DetailTab::ChangeSets.name(), "Change sets");
369 assert_eq!(DetailTab::GitSync.name(), "Git sync");
370 }
371
372 #[test]
373 fn test_detail_tab_next() {
374 assert_eq!(DetailTab::StackInfo.next(), DetailTab::Events);
375 }
376
377 #[test]
378 fn test_column_names() {
379 assert_eq!(Column::Name.name(), "Stack Name");
380 assert_eq!(Column::StackId.name(), "Stack ID");
381 assert_eq!(Column::Status.name(), "Status");
382 assert_eq!(Column::CreatedTime.name(), "Created Time");
383 assert_eq!(Column::UpdatedTime.name(), "Updated Time");
384 assert_eq!(Column::DeletedTime.name(), "Deleted Time");
385 assert_eq!(Column::DriftStatus.name(), "Drift Status");
386 assert_eq!(Column::LastDriftCheckTime.name(), "Last Drift Check Time");
387 assert_eq!(Column::StatusReason.name(), "Status Reason");
388 assert_eq!(Column::Description.name(), "Description");
389 }
390
391 #[test]
392 fn test_column_all() {
393 let columns = Column::ids();
394 assert_eq!(columns.len(), 10);
395 assert_eq!(columns[0], Column::Name.id());
396 assert_eq!(columns[9], Column::Description.id());
397 }
398
399 #[test]
400 fn test_format_status_complete_green() {
401 let (formatted, color) = format_status("UPDATE_COMPLETE");
402 assert_eq!(formatted, "✅ UPDATE_COMPLETE");
403 assert_eq!(color, ratatui::style::Color::Green);
404
405 let (formatted, color) = format_status("CREATE_COMPLETE");
406 assert_eq!(formatted, "✅ CREATE_COMPLETE");
407 assert_eq!(color, ratatui::style::Color::Green);
408
409 let (formatted, color) = format_status("DELETE_COMPLETE");
410 assert_eq!(formatted, "✅ DELETE_COMPLETE");
411 assert_eq!(color, ratatui::style::Color::Green);
412 }
413
414 #[test]
415 fn test_format_status_failed_red() {
416 let (formatted, color) = format_status("UPDATE_FAILED");
417 assert_eq!(formatted, "❌ UPDATE_FAILED");
418 assert_eq!(color, ratatui::style::Color::Red);
419
420 let (formatted, color) = format_status("CREATE_FAILED");
421 assert_eq!(formatted, "❌ CREATE_FAILED");
422 assert_eq!(color, ratatui::style::Color::Red);
423
424 let (formatted, color) = format_status("DELETE_FAILED");
425 assert_eq!(formatted, "❌ DELETE_FAILED");
426 assert_eq!(color, ratatui::style::Color::Red);
427
428 let (formatted, color) = format_status("ROLLBACK_FAILED");
429 assert_eq!(formatted, "❌ ROLLBACK_FAILED");
430 assert_eq!(color, ratatui::style::Color::Red);
431 }
432
433 #[test]
434 fn test_format_status_rollback_red() {
435 let (formatted, color) = format_status("ROLLBACK_COMPLETE");
436 assert_eq!(formatted, "❌ ROLLBACK_COMPLETE");
437 assert_eq!(color, ratatui::style::Color::Red);
438
439 let (formatted, color) = format_status("UPDATE_ROLLBACK_COMPLETE");
440 assert_eq!(formatted, "❌ UPDATE_ROLLBACK_COMPLETE");
441 assert_eq!(color, ratatui::style::Color::Red);
442
443 let (formatted, color) = format_status("UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS");
444 assert_eq!(formatted, "❌ UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS");
445 assert_eq!(color, ratatui::style::Color::Red);
446 }
447
448 #[test]
449 fn test_format_status_in_progress_blue() {
450 let (formatted, color) = format_status("UPDATE_IN_PROGRESS");
451 assert_eq!(formatted, "ℹ️ UPDATE_IN_PROGRESS");
452 assert_eq!(color, ratatui::style::Color::Blue);
453
454 let (formatted, color) = format_status("CREATE_IN_PROGRESS");
455 assert_eq!(formatted, "ℹ️ CREATE_IN_PROGRESS");
456 assert_eq!(color, ratatui::style::Color::Blue);
457
458 let (formatted, color) = format_status("DELETE_IN_PROGRESS");
459 assert_eq!(formatted, "ℹ️ DELETE_IN_PROGRESS");
460 assert_eq!(color, ratatui::style::Color::Blue);
461
462 let (formatted, color) = format_status("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS");
463 assert_eq!(formatted, "ℹ️ UPDATE_COMPLETE_CLEANUP_IN_PROGRESS");
464 assert_eq!(color, ratatui::style::Color::Blue);
465
466 let (formatted, color) = format_status("ROLLBACK_IN_PROGRESS");
467 assert_eq!(formatted, "ℹ️ ROLLBACK_IN_PROGRESS");
468 assert_eq!(color, ratatui::style::Color::Blue);
469
470 let (formatted, color) = format_status("UPDATE_ROLLBACK_IN_PROGRESS");
471 assert_eq!(formatted, "ℹ️ UPDATE_ROLLBACK_IN_PROGRESS");
472 assert_eq!(color, ratatui::style::Color::Blue);
473 }
474
475 #[test]
476 fn test_format_status_unknown() {
477 let (formatted, color) = format_status("UNKNOWN_STATUS");
478 assert_eq!(formatted, "UNKNOWN_STATUS");
479 assert_eq!(color, ratatui::style::Color::White);
480 }
481
482 #[test]
483 fn test_format_status_emoji_spacing() {
484 let (formatted, _) = format_status("CREATE_IN_PROGRESS");
486 assert!(formatted.starts_with("ℹ️ ")); let (formatted, _) = format_status("CREATE_COMPLETE");
489 assert!(formatted.starts_with("✅ ")); let (formatted, _) = format_status("CREATE_FAILED");
492 assert!(formatted.starts_with("❌ ")); }
494
495 #[test]
496 fn test_all_aws_statuses_covered() {
497 let statuses = vec![
499 "CREATE_IN_PROGRESS",
500 "CREATE_FAILED",
501 "CREATE_COMPLETE",
502 "ROLLBACK_IN_PROGRESS",
503 "ROLLBACK_FAILED",
504 "ROLLBACK_COMPLETE",
505 "DELETE_IN_PROGRESS",
506 "DELETE_FAILED",
507 "DELETE_COMPLETE",
508 "UPDATE_IN_PROGRESS",
509 "UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
510 "UPDATE_COMPLETE",
511 "UPDATE_FAILED",
512 "UPDATE_ROLLBACK_IN_PROGRESS",
513 "UPDATE_ROLLBACK_FAILED",
514 "UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
515 "UPDATE_ROLLBACK_COMPLETE",
516 "REVIEW_IN_PROGRESS",
517 "IMPORT_IN_PROGRESS",
518 "IMPORT_COMPLETE",
519 "IMPORT_ROLLBACK_IN_PROGRESS",
520 "IMPORT_ROLLBACK_FAILED",
521 "IMPORT_ROLLBACK_COMPLETE",
522 ];
523
524 for status in statuses {
525 let (formatted, _) = format_status(status);
526 assert!(!formatted.is_empty());
528 assert!(formatted.len() > 2); }
530 }
531
532 #[test]
533 fn test_column_ids_have_correct_prefix() {
534 for col in Column::all() {
535 assert!(
536 col.id().starts_with("column.cfn.stack."),
537 "Column ID '{}' should start with 'column.cfn.stack.'",
538 col.id()
539 );
540 }
541 }
542}