Skip to main content

rusticity_term/cfn/
mod.rs

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