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    if !assessment.not_assessed.is_empty() {
134        output.push_str(&t::subsection_header(
135            "Not Assessed (no data; not in the score)",
136        ));
137        output.push('\n');
138        for reason in &assessment.not_assessed {
139            output.push_str(&t::bullet_row(reason));
140            output.push('\n');
141        }
142    }
143
144    // Recommendations
145    if !assessment.recommendations.is_empty() {
146        output.push_str(&t::subsection_header("Recommendations"));
147        output.push('\n');
148
149        for (i, rec) in assessment.recommendations.iter().enumerate() {
150            output.push_str(&t::numbered_row(i + 1, rec));
151            output.push('\n');
152        }
153    }
154
155    output.push_str(&t::section_footer());
156    output.push('\n');
157
158    output
159}
160
161/// Format as markdown report
162fn format_risk_markdown(assessment: &RiskAssessment, detailed: bool) -> String {
163    let mut md = String::new();
164
165    md.push_str("# Risk Assessment Report\n\n");
166    md.push_str(&format!("**Address:** `{}`\n\n", assessment.address));
167    md.push_str(&format!("**Chain:** {}\n\n", assessment.chain));
168    md.push_str(&format!(
169        "**Risk Score:** {:.1}/10\n\n",
170        assessment.overall_score
171    ));
172    md.push_str(&format!(
173        "**Risk Level:** {} {:?}\n\n",
174        assessment.risk_level.emoji(),
175        assessment.risk_level
176    ));
177    md.push_str(&format!(
178        "**Assessed At:** {}\n\n",
179        assessment.assessed_at.format("%Y-%m-%d %H:%M UTC")
180    ));
181
182    if detailed {
183        md.push_str("## Risk Factor Breakdown\n\n");
184        md.push_str("| Factor | Category | Score | Weight | Weighted |\n");
185        md.push_str("|--------|----------|-------|--------|----------|\n");
186
187        for factor in &assessment.factors {
188            let weighted = factor.score * factor.weight;
189            md.push_str(&format!(
190                "| {} | {:?} | {:.1} | {:.0}% | {:.2} |\n",
191                factor.name,
192                factor.category,
193                factor.score,
194                factor.weight * 100.0,
195                weighted
196            ));
197        }
198
199        md.push('\n');
200
201        // Detailed factor descriptions
202        md.push_str("## Factor Details\n\n");
203        for factor in &assessment.factors {
204            md.push_str(&format!("### {} ({:?})\n\n", factor.name, factor.category));
205            md.push_str(&format!("{}\n\n", factor.description));
206
207            if !factor.evidence.is_empty() {
208                md.push_str("**Evidence:**\n");
209                for ev in &factor.evidence {
210                    md.push_str(&format!("- {}\n", ev));
211                }
212                md.push('\n');
213            }
214        }
215    }
216
217    if !assessment.not_assessed.is_empty() {
218        md.push_str("## Not Assessed\n\nThese factors had no data. They are not in the score.\n\n");
219        for reason in &assessment.not_assessed {
220            md.push_str(&format!("- {}\n", reason));
221        }
222        md.push('\n');
223    }
224
225    if !assessment.recommendations.is_empty() {
226        md.push_str("## Recommendations\n\n");
227        for rec in &assessment.recommendations {
228            md.push_str(&format!("- {}\n", rec));
229        }
230        md.push('\n');
231    }
232
233    md.push_str("---\n\n");
234    md.push_str("*This report was generated automatically. Always verify data from primary sources before making compliance decisions.*\n");
235
236    md
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
243    use chrono::Utc;
244
245    fn create_test_assessment() -> RiskAssessment {
246        RiskAssessment {
247            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
248            chain: "ethereum".to_string(),
249            overall_score: 4.5,
250            risk_level: RiskLevel::Medium,
251            factors: vec![
252                RiskFactor {
253                    name: "Behavioral".to_string(),
254                    category: RiskCategory::Behavioral,
255                    score: 3.0,
256                    weight: 0.25,
257                    description: "Test behavioral".to_string(),
258                    evidence: vec!["Evidence 1".to_string()],
259                },
260                RiskFactor {
261                    name: "Association".to_string(),
262                    category: RiskCategory::Association,
263                    score: 6.0,
264                    weight: 0.30,
265                    description: "Test association".to_string(),
266                    evidence: vec!["Evidence 2".to_string()],
267                },
268            ],
269            not_assessed: Vec::new(),
270            assessed_at: Utc::now(),
271            recommendations: vec!["Monitor closely".to_string()],
272        }
273    }
274
275    #[test]
276    fn test_format_risk_report_table() {
277        let assessment = create_test_assessment();
278        let output = format_risk_report(&assessment, OutputFormat::Table, false);
279        assert!(output.contains("Risk Assessment Report"));
280        assert!(output.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
281        assert!(output.contains("ethereum"));
282    }
283
284    #[test]
285    fn test_incomplete_report_lists_what_was_not_assessed() {
286        // A report without data must say so in every format, not read as Low.
287        let mut assessment = create_test_assessment();
288        assessment.risk_level = RiskLevel::Incomplete;
289        assessment.not_assessed = vec!["Source of Funds: no Etherscan API key".to_string()];
290        for format in [OutputFormat::Table, OutputFormat::Markdown] {
291            let output = format_risk_report(&assessment, format, false);
292            assert!(output.contains("Not Assessed"), "{output}");
293            assert!(
294                output.contains("Source of Funds: no Etherscan API key"),
295                "{output}"
296            );
297            assert!(output.contains("Incomplete"), "{output}");
298        }
299        let json = format_risk_report(&assessment, OutputFormat::Json, false);
300        assert!(json.contains("\"not_assessed\""), "{json}");
301    }
302
303    #[test]
304    fn test_format_risk_report_detailed() {
305        let assessment = create_test_assessment();
306        let output = format_risk_report(&assessment, OutputFormat::Table, true);
307        assert!(output.contains("Risk Factor Breakdown"));
308        assert!(output.contains("Behavioral"));
309        assert!(output.contains("Association"));
310    }
311
312    #[test]
313    fn test_format_risk_report_json() {
314        let assessment = create_test_assessment();
315        let output = format_risk_report(&assessment, OutputFormat::Json, false);
316        assert!(output.contains("address"));
317        assert!(output.contains("ethereum"));
318        assert!(output.contains("overall_score"));
319    }
320
321    #[test]
322    fn test_format_risk_report_yaml() {
323        let assessment = create_test_assessment();
324        let output = format_risk_report(&assessment, OutputFormat::Yaml, false);
325        assert!(output.contains("address:"));
326        assert!(output.contains("chain:"));
327    }
328
329    #[test]
330    fn test_format_risk_report_markdown() {
331        let assessment = create_test_assessment();
332        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
333        assert!(output.contains("# Risk Assessment Report"));
334        assert!(output.contains("## Risk Factor Breakdown"));
335        assert!(output.contains("## Recommendations"));
336    }
337
338    #[test]
339    fn test_format_low_risk() {
340        let mut assessment = create_test_assessment();
341        assessment.risk_level = RiskLevel::Low;
342        assessment.overall_score = 2.0;
343
344        let output = format_risk_report(&assessment, OutputFormat::Table, false);
345        assert!(output.contains("🟢"));
346    }
347
348    #[test]
349    fn test_format_high_risk() {
350        let mut assessment = create_test_assessment();
351        assessment.risk_level = RiskLevel::High;
352        assessment.overall_score = 7.5;
353
354        let output = format_risk_report(&assessment, OutputFormat::Table, false);
355        assert!(output.contains("🔴"));
356    }
357
358    #[test]
359    fn test_format_critical_risk() {
360        let mut assessment = create_test_assessment();
361        assessment.risk_level = RiskLevel::Critical;
362        assessment.overall_score = 9.0;
363
364        let output = format_risk_report(&assessment, OutputFormat::Table, false);
365        assert!(output.contains("âš«"));
366    }
367
368    #[test]
369    fn test_empty_recommendations() {
370        let mut assessment = create_test_assessment();
371        assessment.recommendations = vec![];
372
373        let output = format_risk_report(&assessment, OutputFormat::Table, false);
374        // Should not panic and should not contain recommendations section
375        assert!(output.contains("Risk Assessment Report"));
376    }
377
378    #[test]
379    fn test_markdown_no_detailed() {
380        let assessment = create_test_assessment();
381        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
382        // Should not contain detailed factor breakdown when detailed=false
383        assert!(!output.contains("## Risk Factor Breakdown"));
384    }
385
386    // ========================================================================
387    // Additional edge case tests
388    // ========================================================================
389
390    #[test]
391    fn test_format_risk_report_no_factors() {
392        let mut assessment = create_test_assessment();
393        assessment.factors = vec![];
394        // Table, detailed → should still render without factors
395        let output = format_risk_report(&assessment, OutputFormat::Table, true);
396        assert!(output.contains("Risk Assessment Report"));
397        assert!(output.contains("Risk Factor Breakdown"));
398    }
399
400    #[test]
401    fn test_format_risk_markdown_no_factors() {
402        let mut assessment = create_test_assessment();
403        assessment.factors = vec![];
404        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
405        assert!(output.contains("# Risk Assessment Report"));
406        assert!(output.contains("## Risk Factor Breakdown"));
407    }
408
409    #[test]
410    fn test_format_risk_json_roundtrip() {
411        let assessment = create_test_assessment();
412        let json = format_risk_report(&assessment, OutputFormat::Json, false);
413        // Should be valid JSON
414        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415        assert_eq!(
416            parsed["address"],
417            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
418        );
419        assert_eq!(parsed["chain"], "ethereum");
420    }
421
422    #[test]
423    fn test_format_risk_yaml_roundtrip() {
424        let assessment = create_test_assessment();
425        let yaml = format_risk_report(&assessment, OutputFormat::Yaml, false);
426        // Should be valid YAML that can be deserialized back
427        let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
428        assert!(parsed["address"].as_str().unwrap().starts_with("0x742d"));
429    }
430
431    #[test]
432    fn test_format_risk_table_no_recommendations() {
433        let mut assessment = create_test_assessment();
434        assessment.recommendations = vec![];
435        let output = format_risk_report(&assessment, OutputFormat::Table, false);
436        assert!(!output.contains("Recommendations"));
437    }
438
439    #[test]
440    fn test_format_risk_table_many_recommendations() {
441        let mut assessment = create_test_assessment();
442        assessment.recommendations = (0..10).map(|i| format!("Recommendation {}", i)).collect();
443        let output = format_risk_report(&assessment, OutputFormat::Table, false);
444        assert!(output.contains("1."));
445        assert!(output.contains("10."));
446    }
447
448    #[test]
449    fn test_format_risk_markdown_with_evidence() {
450        let mut assessment = create_test_assessment();
451        assessment.factors[0].evidence = vec![
452            "Evidence A".to_string(),
453            "Evidence B".to_string(),
454            "Evidence C".to_string(),
455        ];
456        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
457        assert!(output.contains("Evidence A"));
458        assert!(output.contains("Evidence B"));
459        assert!(output.contains("Evidence C"));
460    }
461
462    #[test]
463    fn test_format_risk_markdown_empty_evidence() {
464        let mut assessment = create_test_assessment();
465        for factor in &mut assessment.factors {
466            factor.evidence = vec![];
467        }
468        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
469        // Should not contain "**Evidence:**" section since evidence is empty
470        assert!(!output.contains("**Evidence:**"));
471    }
472
473    #[test]
474    fn test_format_risk_table_long_factor_name() {
475        let mut assessment = create_test_assessment();
476        assessment.factors[0].name =
477            "A Very Long Factor Name That Exceeds 24 Characters".to_string();
478        let output = format_risk_report(&assessment, OutputFormat::Table, true);
479        // Should be truncated to 24 chars
480        assert!(output.contains("A Very Long Factor Name "));
481    }
482
483    #[test]
484    fn test_all_output_formats_no_panic() {
485        let assessment = create_test_assessment();
486        for format in [
487            OutputFormat::Table,
488            OutputFormat::Json,
489            OutputFormat::Yaml,
490            OutputFormat::Markdown,
491        ] {
492            for detailed in [true, false] {
493                let output = format_risk_report(&assessment, format, detailed);
494                assert!(!output.is_empty());
495            }
496        }
497    }
498
499    #[test]
500    fn test_output_format_default() {
501        let format = OutputFormat::default();
502        assert!(matches!(format, OutputFormat::Table));
503    }
504
505    #[test]
506    fn test_markdown_contains_disclaimer() {
507        let assessment = create_test_assessment();
508        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
509        assert!(output.contains("generated automatically"));
510    }
511
512    #[test]
513    fn test_format_all_risk_levels() {
514        let mut assessment = create_test_assessment();
515        for (level, emoji) in [
516            (RiskLevel::Low, "🟢"),
517            (RiskLevel::Medium, "🟡"),
518            (RiskLevel::High, "🔴"),
519            (RiskLevel::Critical, "âš«"),
520        ] {
521            assessment.risk_level = level;
522            let output = format_risk_report(&assessment, OutputFormat::Table, false);
523            assert!(output.contains(emoji));
524            let md = format_risk_report(&assessment, OutputFormat::Markdown, false);
525            assert!(md.contains(emoji));
526        }
527    }
528
529    #[test]
530    fn test_format_risk_markdown_all_categories() {
531        let mut assessment = create_test_assessment();
532        assessment.factors = vec![
533            RiskFactor {
534                name: "Behavioral".to_string(),
535                category: RiskCategory::Behavioral,
536                score: 3.0,
537                weight: 0.2,
538                description: "Behavioral analysis".to_string(),
539                evidence: vec!["evidence".to_string()],
540            },
541            RiskFactor {
542                name: "Association".to_string(),
543                category: RiskCategory::Association,
544                score: 4.0,
545                weight: 0.2,
546                description: "Association analysis".to_string(),
547                evidence: vec![],
548            },
549            RiskFactor {
550                name: "Source".to_string(),
551                category: RiskCategory::Source,
552                score: 2.0,
553                weight: 0.2,
554                description: "Source analysis".to_string(),
555                evidence: vec![],
556            },
557            RiskFactor {
558                name: "Destination".to_string(),
559                category: RiskCategory::Destination,
560                score: 1.0,
561                weight: 0.2,
562                description: "Destination analysis".to_string(),
563                evidence: vec![],
564            },
565            RiskFactor {
566                name: "Entity".to_string(),
567                category: RiskCategory::Entity,
568                score: 5.0,
569                weight: 0.2,
570                description: "Entity analysis".to_string(),
571                evidence: vec![],
572            },
573        ];
574        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
575        assert!(output.contains("Behavioral"));
576        assert!(output.contains("Association"));
577        assert!(output.contains("Source"));
578        assert!(output.contains("Destination"));
579        assert!(output.contains("Entity"));
580    }
581}