Skip to main content

rusticity_term/
cfn.rs

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        // Verify emojis have proper spacing to avoid overlay
485        let (formatted, _) = format_status("CREATE_IN_PROGRESS");
486        assert!(formatted.starts_with("ℹ️ ")); // One space after info emoji
487
488        let (formatted, _) = format_status("CREATE_COMPLETE");
489        assert!(formatted.starts_with("✅ ")); // One space after checkmark
490
491        let (formatted, _) = format_status("CREATE_FAILED");
492        assert!(formatted.starts_with("❌ ")); // One space after cross
493    }
494
495    #[test]
496    fn test_all_aws_statuses_covered() {
497        // Test all documented CloudFormation stack statuses
498        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            // Ensure all statuses get formatted (no panics) and contain some text
527            assert!(!formatted.is_empty());
528            assert!(formatted.len() > 2); // More than just emoji
529        }
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}