rusticity_term/
cfn.rs

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