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