use crate::db::models::IssueDetail; pub fn generate_rca_markdown(detail: &IssueDetail) -> String { let issue = &detail.issue; let mut md = String::new(); md.push_str(&format!("# Root Cause Analysis: {}\n\n", issue.title)); md.push_str("## Issue Summary\n\n"); md.push_str("| Field | Value |\n"); md.push_str("|-------|-------|\n"); md.push_str(&format!("| **Issue ID** | {} |\n", issue.id)); md.push_str(&format!("| **Category** | {} |\n", issue.category)); md.push_str(&format!("| **Status** | {} |\n", issue.status)); md.push_str(&format!("| **Severity** | {} |\n", issue.severity)); md.push_str(&format!("| **Source** | {} |\n", issue.source)); md.push_str(&format!( "| **Assigned To** | {} |\n", if issue.assigned_to.is_empty() { "Unassigned" } else { &issue.assigned_to } )); md.push_str(&format!("| **Created** | {} |\n", issue.created_at)); md.push_str(&format!("| **Last Updated** | {} |\n", issue.updated_at)); if let Some(ref resolved) = issue.resolved_at { md.push_str(&format!("| **Resolved** | {} |\n", resolved)); } md.push_str("\n"); if !issue.description.is_empty() { md.push_str("## Description\n\n"); md.push_str(&issue.description); md.push_str("\n\n"); } // 5 Whys Analysis md.push_str("## 5 Whys Analysis\n\n"); if detail.resolution_steps.is_empty() { md.push_str("_No 5-whys analysis has been performed yet._\n\n"); } else { for step in &detail.resolution_steps { md.push_str(&format!( "### Why #{}: {}\n\n", step.step_order, step.why_question )); if !step.answer.is_empty() { md.push_str(&format!("**Answer:** {}\n\n", step.answer)); } else { md.push_str("_Awaiting answer._\n\n"); } if !step.evidence.is_empty() { md.push_str(&format!("**Evidence:** {}\n\n", step.evidence)); } } } // Root Cause md.push_str("## Root Cause\n\n"); if let Some(last_step) = detail.resolution_steps.last() { if !last_step.answer.is_empty() { md.push_str(&format!( "Based on the 5-whys analysis, the root cause is:\n\n> {}\n\n", last_step.answer )); } else { md.push_str( "_The 5-whys analysis is incomplete. Complete it to identify the root cause._\n\n", ); } } else { md.push_str("_Perform the 5-whys analysis to identify the root cause._\n\n"); } // Log Files md.push_str("## Log Files Analyzed\n\n"); if detail.log_files.is_empty() { md.push_str("_No log files attached._\n\n"); } else { md.push_str("| File | Size | Redacted | Hash |\n"); md.push_str("|------|------|----------|------|\n"); for lf in &detail.log_files { md.push_str(&format!( "| {} | {} bytes | {} | {}... |\n", lf.file_name, lf.file_size, if lf.redacted { "Yes" } else { "No" }, &lf.content_hash[..8.min(lf.content_hash.len())], )); } md.push_str("\n"); } // Corrective Actions md.push_str("## Corrective Actions\n\n"); md.push_str("### Immediate Actions\n\n"); md.push_str("- [ ] _Document immediate mitigations taken_\n\n"); md.push_str("### Long-Term Actions\n\n"); md.push_str("- [ ] _Document preventive measures_\n"); md.push_str("- [ ] _Document monitoring improvements_\n\n"); // Lessons Learned md.push_str("## Lessons Learned\n\n"); md.push_str("- _What went well during the response?_\n"); md.push_str("- _What could be improved?_\n"); md.push_str("- _What processes need updating?_\n\n"); md.push_str("---\n\n"); md.push_str(&format!( "_Generated by TFTSR IT Triage on {}_\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") )); md } #[cfg(test)] mod tests { use super::*; use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep}; fn make_test_detail() -> IssueDetail { IssueDetail { issue: Issue { id: "test-123".to_string(), title: "Database connection timeout".to_string(), description: "Users report 500 errors on login.".to_string(), severity: "high".to_string(), status: "investigating".to_string(), category: "database".to_string(), source: "manual".to_string(), created_at: "2025-01-15 10:00:00".to_string(), updated_at: "2025-01-15 12:00:00".to_string(), resolved_at: None, assigned_to: "oncall-eng".to_string(), tags: "[]".to_string(), }, log_files: vec![LogFile { id: "lf-1".to_string(), issue_id: "test-123".to_string(), file_name: "app.log".to_string(), file_path: "/tmp/app.log".to_string(), file_size: 2048, mime_type: "text/plain".to_string(), content_hash: "abc123def456".to_string(), uploaded_at: "2025-01-15 10:30:00".to_string(), redacted: false, }], resolution_steps: vec![ ResolutionStep { id: "rs-1".to_string(), issue_id: "test-123".to_string(), step_order: 1, why_question: "Why are users getting 500 errors?".to_string(), answer: "The database connection pool is exhausted.".to_string(), evidence: "Connection pool metrics show 100/100 used.".to_string(), created_at: "2025-01-15 11:00:00".to_string(), }, ResolutionStep { id: "rs-2".to_string(), issue_id: "test-123".to_string(), step_order: 2, why_question: "Why is the connection pool exhausted?".to_string(), answer: "Queries are not being released after completion.".to_string(), evidence: "".to_string(), created_at: "2025-01-15 11:15:00".to_string(), }, ], conversations: vec![], } } #[test] fn test_rca_contains_title() { let md = generate_rca_markdown(&make_test_detail()); assert!(md.contains("# Root Cause Analysis: Database connection timeout")); } #[test] fn test_rca_contains_issue_summary_table() { let md = generate_rca_markdown(&make_test_detail()); assert!(md.contains("| **Issue ID** | test-123 |")); assert!(md.contains("| **Severity** | high |")); assert!(md.contains("| **Assigned To** | oncall-eng |")); } #[test] fn test_rca_contains_five_whys() { let md = generate_rca_markdown(&make_test_detail()); assert!(md.contains("### Why #1: Why are users getting 500 errors?")); assert!(md.contains("**Answer:** The database connection pool is exhausted.")); assert!(md.contains("**Evidence:** Connection pool metrics")); } #[test] fn test_rca_contains_root_cause() { let md = generate_rca_markdown(&make_test_detail()); assert!(md.contains("Queries are not being released after completion.")); } #[test] fn test_rca_contains_log_files() { let md = generate_rca_markdown(&make_test_detail()); assert!(md.contains("app.log")); assert!(md.contains("2048 bytes")); } #[test] fn test_rca_empty_steps_shows_placeholder() { let mut detail = make_test_detail(); detail.resolution_steps.clear(); let md = generate_rca_markdown(&detail); assert!(md.contains("No 5-whys analysis has been performed yet.")); } #[test] fn test_rca_unassigned_shows_unassigned() { let mut detail = make_test_detail(); detail.issue.assigned_to = String::new(); let md = generate_rca_markdown(&detail); assert!(md.contains("Unassigned")); } }