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