Skip to main content

scope/display/
compliance.rs

1//! Display formatting for compliance reports
2
3use crate::compliance::risk::RiskAssessment;
4// Note: Using simple table formatting for now
5// For production, add comfy_table to Cargo.toml
6
7/// Output format options
8#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
9pub enum OutputFormat {
10    #[default]
11    Table,
12    Json,
13    Yaml,
14    Markdown,
15}
16
17/// Format a risk assessment report
18pub fn format_risk_report(
19    assessment: &RiskAssessment,
20    format: OutputFormat,
21    detailed: bool,
22) -> String {
23    match format {
24        OutputFormat::Table => format_risk_table(assessment, detailed),
25        OutputFormat::Json => serde_json::to_string_pretty(assessment).unwrap_or_default(),
26        OutputFormat::Yaml => serde_yaml::to_string(assessment).unwrap_or_default(),
27        OutputFormat::Markdown => format_risk_markdown(assessment, detailed),
28    }
29}
30
31/// Format as pretty table
32fn format_risk_table(assessment: &RiskAssessment, detailed: bool) -> String {
33    let mut output = String::new();
34
35    // Header
36    output.push_str(&format!(
37        "\n{} Risk Assessment Report\n",
38        assessment.risk_level.emoji()
39    ));
40    output.push_str(&"═".repeat(60));
41    output.push('\n');
42
43    // Summary section (simple text format)
44    output.push_str(&format!("{:<20} {}\n", "Address:", assessment.address));
45    output.push_str(&format!("{:<20} {}\n", "Chain:", assessment.chain));
46    output.push_str(&format!(
47        "{:<20} {:.1}/10\n",
48        "Risk Score:", assessment.overall_score
49    ));
50    output.push_str(&format!(
51        "{:<20} {} {:?}\n",
52        "Risk Level:",
53        assessment.risk_level.emoji(),
54        assessment.risk_level
55    ));
56    output.push_str(&format!(
57        "{:<20} {}\n",
58        "Assessed At:",
59        assessment.assessed_at.format("%Y-%m-%d %H:%M UTC")
60    ));
61
62    // Risk factors
63    if detailed {
64        output.push_str("\nšŸ“Š Risk Factor Breakdown\n");
65        output.push_str(&"─".repeat(60));
66        output.push('\n');
67        output.push_str(&format!(
68            "{:<25} {:<12} {:<8} {:<8} {:<10}\n",
69            "Factor", "Category", "Score", "Weight", "Weighted"
70        ));
71        output.push_str(&"─".repeat(60));
72        output.push('\n');
73
74        for factor in &assessment.factors {
75            let weighted = factor.score * factor.weight;
76            output.push_str(&format!(
77                "{:<25} {:<12} {:<8.1} {:<8.0}% {:<10.2}\n",
78                factor.name.chars().take(24).collect::<String>(),
79                format!("{:?}", factor.category)
80                    .chars()
81                    .take(11)
82                    .collect::<String>(),
83                factor.score,
84                factor.weight * 100.0,
85                weighted
86            ));
87        }
88    }
89
90    // Recommendations
91    if !assessment.recommendations.is_empty() {
92        output.push_str("\nšŸ’” Recommendations\n");
93        output.push_str(&"─".repeat(60));
94        output.push('\n');
95
96        for (i, rec) in assessment.recommendations.iter().enumerate() {
97            output.push_str(&format!("{}. {}\n", i + 1, rec));
98        }
99    }
100
101    output
102}
103
104/// Format as markdown report
105fn format_risk_markdown(assessment: &RiskAssessment, detailed: bool) -> String {
106    let mut md = String::new();
107
108    md.push_str("# Risk Assessment Report\n\n");
109    md.push_str(&format!("**Address:** `{}`\n\n", assessment.address));
110    md.push_str(&format!("**Chain:** {}\n\n", assessment.chain));
111    md.push_str(&format!(
112        "**Risk Score:** {:.1}/10\n\n",
113        assessment.overall_score
114    ));
115    md.push_str(&format!(
116        "**Risk Level:** {} {:?}\n\n",
117        assessment.risk_level.emoji(),
118        assessment.risk_level
119    ));
120    md.push_str(&format!(
121        "**Assessed At:** {}\n\n",
122        assessment.assessed_at.format("%Y-%m-%d %H:%M UTC")
123    ));
124
125    if detailed {
126        md.push_str("## Risk Factor Breakdown\n\n");
127        md.push_str("| Factor | Category | Score | Weight | Weighted |\n");
128        md.push_str("|--------|----------|-------|--------|----------|\n");
129
130        for factor in &assessment.factors {
131            let weighted = factor.score * factor.weight;
132            md.push_str(&format!(
133                "| {} | {:?} | {:.1} | {:.0}% | {:.2} |\n",
134                factor.name,
135                factor.category,
136                factor.score,
137                factor.weight * 100.0,
138                weighted
139            ));
140        }
141
142        md.push('\n');
143
144        // Detailed factor descriptions
145        md.push_str("## Factor Details\n\n");
146        for factor in &assessment.factors {
147            md.push_str(&format!("### {} ({:?})\n\n", factor.name, factor.category));
148            md.push_str(&format!("{}\n\n", factor.description));
149
150            if !factor.evidence.is_empty() {
151                md.push_str("**Evidence:**\n");
152                for ev in &factor.evidence {
153                    md.push_str(&format!("- {}\n", ev));
154                }
155                md.push('\n');
156            }
157        }
158    }
159
160    if !assessment.recommendations.is_empty() {
161        md.push_str("## Recommendations\n\n");
162        for rec in &assessment.recommendations {
163            md.push_str(&format!("- {}\n", rec));
164        }
165        md.push('\n');
166    }
167
168    md.push_str("---\n\n");
169    md.push_str("*This report was generated automatically. Always verify data from primary sources before making compliance decisions.*\n");
170
171    md
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
178    use chrono::Utc;
179
180    fn create_test_assessment() -> RiskAssessment {
181        RiskAssessment {
182            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
183            chain: "ethereum".to_string(),
184            overall_score: 4.5,
185            risk_level: RiskLevel::Medium,
186            factors: vec![
187                RiskFactor {
188                    name: "Behavioral".to_string(),
189                    category: RiskCategory::Behavioral,
190                    score: 3.0,
191                    weight: 0.25,
192                    description: "Test behavioral".to_string(),
193                    evidence: vec!["Evidence 1".to_string()],
194                },
195                RiskFactor {
196                    name: "Association".to_string(),
197                    category: RiskCategory::Association,
198                    score: 6.0,
199                    weight: 0.30,
200                    description: "Test association".to_string(),
201                    evidence: vec!["Evidence 2".to_string()],
202                },
203            ],
204            assessed_at: Utc::now(),
205            recommendations: vec!["Monitor closely".to_string()],
206        }
207    }
208
209    #[test]
210    fn test_format_risk_report_table() {
211        let assessment = create_test_assessment();
212        let output = format_risk_report(&assessment, OutputFormat::Table, false);
213        assert!(output.contains("Risk Assessment Report"));
214        assert!(output.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
215        assert!(output.contains("ethereum"));
216    }
217
218    #[test]
219    fn test_format_risk_report_detailed() {
220        let assessment = create_test_assessment();
221        let output = format_risk_report(&assessment, OutputFormat::Table, true);
222        assert!(output.contains("Risk Factor Breakdown"));
223        assert!(output.contains("Behavioral"));
224        assert!(output.contains("Association"));
225    }
226
227    #[test]
228    fn test_format_risk_report_json() {
229        let assessment = create_test_assessment();
230        let output = format_risk_report(&assessment, OutputFormat::Json, false);
231        assert!(output.contains("address"));
232        assert!(output.contains("ethereum"));
233        assert!(output.contains("overall_score"));
234    }
235
236    #[test]
237    fn test_format_risk_report_yaml() {
238        let assessment = create_test_assessment();
239        let output = format_risk_report(&assessment, OutputFormat::Yaml, false);
240        assert!(output.contains("address:"));
241        assert!(output.contains("chain:"));
242    }
243
244    #[test]
245    fn test_format_risk_report_markdown() {
246        let assessment = create_test_assessment();
247        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
248        assert!(output.contains("# Risk Assessment Report"));
249        assert!(output.contains("## Risk Factor Breakdown"));
250        assert!(output.contains("## Recommendations"));
251    }
252
253    #[test]
254    fn test_format_low_risk() {
255        let mut assessment = create_test_assessment();
256        assessment.risk_level = RiskLevel::Low;
257        assessment.overall_score = 2.0;
258
259        let output = format_risk_report(&assessment, OutputFormat::Table, false);
260        assert!(output.contains("🟢"));
261    }
262
263    #[test]
264    fn test_format_high_risk() {
265        let mut assessment = create_test_assessment();
266        assessment.risk_level = RiskLevel::High;
267        assessment.overall_score = 7.5;
268
269        let output = format_risk_report(&assessment, OutputFormat::Table, false);
270        assert!(output.contains("šŸ”“"));
271    }
272
273    #[test]
274    fn test_format_critical_risk() {
275        let mut assessment = create_test_assessment();
276        assessment.risk_level = RiskLevel::Critical;
277        assessment.overall_score = 9.0;
278
279        let output = format_risk_report(&assessment, OutputFormat::Table, false);
280        assert!(output.contains("⚫"));
281    }
282
283    #[test]
284    fn test_empty_recommendations() {
285        let mut assessment = create_test_assessment();
286        assessment.recommendations = vec![];
287
288        let output = format_risk_report(&assessment, OutputFormat::Table, false);
289        // Should not panic and should not contain recommendations section
290        assert!(output.contains("Risk Assessment Report"));
291    }
292
293    #[test]
294    fn test_markdown_no_detailed() {
295        let assessment = create_test_assessment();
296        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
297        // Should not contain detailed factor breakdown when detailed=false
298        assert!(!output.contains("## Risk Factor Breakdown"));
299    }
300
301    // ========================================================================
302    // Additional edge case tests
303    // ========================================================================
304
305    #[test]
306    fn test_format_risk_report_no_factors() {
307        let mut assessment = create_test_assessment();
308        assessment.factors = vec![];
309        // Table, detailed → should still render without factors
310        let output = format_risk_report(&assessment, OutputFormat::Table, true);
311        assert!(output.contains("Risk Assessment Report"));
312        assert!(output.contains("Risk Factor Breakdown"));
313    }
314
315    #[test]
316    fn test_format_risk_markdown_no_factors() {
317        let mut assessment = create_test_assessment();
318        assessment.factors = vec![];
319        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
320        assert!(output.contains("# Risk Assessment Report"));
321        assert!(output.contains("## Risk Factor Breakdown"));
322    }
323
324    #[test]
325    fn test_format_risk_json_roundtrip() {
326        let assessment = create_test_assessment();
327        let json = format_risk_report(&assessment, OutputFormat::Json, false);
328        // Should be valid JSON
329        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
330        assert_eq!(
331            parsed["address"],
332            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
333        );
334        assert_eq!(parsed["chain"], "ethereum");
335    }
336
337    #[test]
338    fn test_format_risk_yaml_roundtrip() {
339        let assessment = create_test_assessment();
340        let yaml = format_risk_report(&assessment, OutputFormat::Yaml, false);
341        // Should be valid YAML that can be deserialized back
342        let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
343        assert!(parsed["address"].as_str().unwrap().starts_with("0x742d"));
344    }
345
346    #[test]
347    fn test_format_risk_table_no_recommendations() {
348        let mut assessment = create_test_assessment();
349        assessment.recommendations = vec![];
350        let output = format_risk_report(&assessment, OutputFormat::Table, false);
351        assert!(!output.contains("Recommendations"));
352    }
353
354    #[test]
355    fn test_format_risk_table_many_recommendations() {
356        let mut assessment = create_test_assessment();
357        assessment.recommendations = (0..10).map(|i| format!("Recommendation {}", i)).collect();
358        let output = format_risk_report(&assessment, OutputFormat::Table, false);
359        assert!(output.contains("1."));
360        assert!(output.contains("10."));
361    }
362
363    #[test]
364    fn test_format_risk_markdown_with_evidence() {
365        let mut assessment = create_test_assessment();
366        assessment.factors[0].evidence = vec![
367            "Evidence A".to_string(),
368            "Evidence B".to_string(),
369            "Evidence C".to_string(),
370        ];
371        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
372        assert!(output.contains("Evidence A"));
373        assert!(output.contains("Evidence B"));
374        assert!(output.contains("Evidence C"));
375    }
376
377    #[test]
378    fn test_format_risk_markdown_empty_evidence() {
379        let mut assessment = create_test_assessment();
380        for factor in &mut assessment.factors {
381            factor.evidence = vec![];
382        }
383        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
384        // Should not contain "**Evidence:**" section since evidence is empty
385        assert!(!output.contains("**Evidence:**"));
386    }
387
388    #[test]
389    fn test_format_risk_table_long_factor_name() {
390        let mut assessment = create_test_assessment();
391        assessment.factors[0].name =
392            "A Very Long Factor Name That Exceeds 24 Characters".to_string();
393        let output = format_risk_report(&assessment, OutputFormat::Table, true);
394        // Should be truncated to 24 chars
395        assert!(output.contains("A Very Long Factor Name "));
396    }
397
398    #[test]
399    fn test_all_output_formats_no_panic() {
400        let assessment = create_test_assessment();
401        for format in [
402            OutputFormat::Table,
403            OutputFormat::Json,
404            OutputFormat::Yaml,
405            OutputFormat::Markdown,
406        ] {
407            for detailed in [true, false] {
408                let output = format_risk_report(&assessment, format, detailed);
409                assert!(!output.is_empty());
410            }
411        }
412    }
413
414    #[test]
415    fn test_output_format_default() {
416        let format = OutputFormat::default();
417        assert!(matches!(format, OutputFormat::Table));
418    }
419
420    #[test]
421    fn test_markdown_contains_disclaimer() {
422        let assessment = create_test_assessment();
423        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
424        assert!(output.contains("generated automatically"));
425    }
426
427    #[test]
428    fn test_format_all_risk_levels() {
429        let mut assessment = create_test_assessment();
430        for (level, emoji) in [
431            (RiskLevel::Low, "🟢"),
432            (RiskLevel::Medium, "🟔"),
433            (RiskLevel::High, "šŸ”“"),
434            (RiskLevel::Critical, "⚫"),
435        ] {
436            assessment.risk_level = level;
437            let output = format_risk_report(&assessment, OutputFormat::Table, false);
438            assert!(output.contains(emoji));
439            let md = format_risk_report(&assessment, OutputFormat::Markdown, false);
440            assert!(md.contains(emoji));
441        }
442    }
443
444    #[test]
445    fn test_format_risk_markdown_all_categories() {
446        let mut assessment = create_test_assessment();
447        assessment.factors = vec![
448            RiskFactor {
449                name: "Behavioral".to_string(),
450                category: RiskCategory::Behavioral,
451                score: 3.0,
452                weight: 0.2,
453                description: "Behavioral analysis".to_string(),
454                evidence: vec!["evidence".to_string()],
455            },
456            RiskFactor {
457                name: "Association".to_string(),
458                category: RiskCategory::Association,
459                score: 4.0,
460                weight: 0.2,
461                description: "Association analysis".to_string(),
462                evidence: vec![],
463            },
464            RiskFactor {
465                name: "Source".to_string(),
466                category: RiskCategory::Source,
467                score: 2.0,
468                weight: 0.2,
469                description: "Source analysis".to_string(),
470                evidence: vec![],
471            },
472            RiskFactor {
473                name: "Destination".to_string(),
474                category: RiskCategory::Destination,
475                score: 1.0,
476                weight: 0.2,
477                description: "Destination analysis".to_string(),
478                evidence: vec![],
479            },
480            RiskFactor {
481                name: "Entity".to_string(),
482                category: RiskCategory::Entity,
483                score: 5.0,
484                weight: 0.2,
485                description: "Entity analysis".to_string(),
486                evidence: vec![],
487            },
488        ];
489        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
490        assert!(output.contains("Behavioral"));
491        assert!(output.contains("Association"));
492        assert!(output.contains("Source"));
493        assert!(output.contains("Destination"));
494        assert!(output.contains("Entity"));
495    }
496}