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_saphyr::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        // The table wraps at the terminal width, which width tests change.
287        let _lock = crate::display::terminal::width_lock();
288        // A report without data must say so in every format, not read as Low.
289        let mut assessment = create_test_assessment();
290        assessment.risk_level = RiskLevel::Incomplete;
291        assessment.not_assessed = vec!["Source of Funds: no Etherscan API key".to_string()];
292        for format in [OutputFormat::Table, OutputFormat::Markdown] {
293            let output = format_risk_report(&assessment, format, false);
294            assert!(output.contains("Not Assessed"), "{output}");
295            assert!(
296                output.contains("Source of Funds: no Etherscan API key"),
297                "{output}"
298            );
299            assert!(output.contains("Incomplete"), "{output}");
300        }
301        let json = format_risk_report(&assessment, OutputFormat::Json, false);
302        assert!(json.contains("\"not_assessed\""), "{json}");
303    }
304
305    #[test]
306    fn test_format_risk_report_detailed() {
307        let assessment = create_test_assessment();
308        let output = format_risk_report(&assessment, OutputFormat::Table, true);
309        assert!(output.contains("Risk Factor Breakdown"));
310        assert!(output.contains("Behavioral"));
311        assert!(output.contains("Association"));
312    }
313
314    #[test]
315    fn test_format_risk_report_json() {
316        let assessment = create_test_assessment();
317        let output = format_risk_report(&assessment, OutputFormat::Json, false);
318        assert!(output.contains("address"));
319        assert!(output.contains("ethereum"));
320        assert!(output.contains("overall_score"));
321    }
322
323    #[test]
324    fn test_format_risk_report_yaml() {
325        let assessment = create_test_assessment();
326        let output = format_risk_report(&assessment, OutputFormat::Yaml, false);
327        assert!(output.contains("address:"));
328        assert!(output.contains("chain:"));
329    }
330
331    #[test]
332    fn test_format_risk_report_markdown() {
333        let assessment = create_test_assessment();
334        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
335        assert!(output.contains("# Risk Assessment Report"));
336        assert!(output.contains("## Risk Factor Breakdown"));
337        assert!(output.contains("## Recommendations"));
338    }
339
340    #[test]
341    fn test_format_low_risk() {
342        let mut assessment = create_test_assessment();
343        assessment.risk_level = RiskLevel::Low;
344        assessment.overall_score = 2.0;
345
346        let output = format_risk_report(&assessment, OutputFormat::Table, false);
347        assert!(output.contains("🟢"));
348    }
349
350    #[test]
351    fn test_format_high_risk() {
352        let mut assessment = create_test_assessment();
353        assessment.risk_level = RiskLevel::High;
354        assessment.overall_score = 7.5;
355
356        let output = format_risk_report(&assessment, OutputFormat::Table, false);
357        assert!(output.contains("🔴"));
358    }
359
360    #[test]
361    fn test_format_critical_risk() {
362        let mut assessment = create_test_assessment();
363        assessment.risk_level = RiskLevel::Critical;
364        assessment.overall_score = 9.0;
365
366        let output = format_risk_report(&assessment, OutputFormat::Table, false);
367        assert!(output.contains("âš«"));
368    }
369
370    #[test]
371    fn test_empty_recommendations() {
372        let mut assessment = create_test_assessment();
373        assessment.recommendations = vec![];
374
375        let output = format_risk_report(&assessment, OutputFormat::Table, false);
376        // Should not panic and should not contain recommendations section
377        assert!(output.contains("Risk Assessment Report"));
378    }
379
380    #[test]
381    fn test_markdown_no_detailed() {
382        let assessment = create_test_assessment();
383        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
384        // Should not contain detailed factor breakdown when detailed=false
385        assert!(!output.contains("## Risk Factor Breakdown"));
386    }
387
388    // ========================================================================
389    // Additional edge case tests
390    // ========================================================================
391
392    #[test]
393    fn test_format_risk_report_no_factors() {
394        let mut assessment = create_test_assessment();
395        assessment.factors = vec![];
396        // Table, detailed → should still render without factors
397        let output = format_risk_report(&assessment, OutputFormat::Table, true);
398        assert!(output.contains("Risk Assessment Report"));
399        assert!(output.contains("Risk Factor Breakdown"));
400    }
401
402    #[test]
403    fn test_format_risk_markdown_no_factors() {
404        let mut assessment = create_test_assessment();
405        assessment.factors = vec![];
406        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
407        assert!(output.contains("# Risk Assessment Report"));
408        assert!(output.contains("## Risk Factor Breakdown"));
409    }
410
411    #[test]
412    fn test_format_risk_json_roundtrip() {
413        let assessment = create_test_assessment();
414        let json = format_risk_report(&assessment, OutputFormat::Json, false);
415        // Should be valid JSON
416        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
417        assert_eq!(
418            parsed["address"],
419            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
420        );
421        assert_eq!(parsed["chain"], "ethereum");
422    }
423
424    #[test]
425    fn test_format_risk_yaml_roundtrip() {
426        let assessment = create_test_assessment();
427        let yaml = format_risk_report(&assessment, OutputFormat::Yaml, false);
428        // Should be valid YAML that can be deserialized back
429        let parsed: serde_json::Value = serde_saphyr::from_str(&yaml).unwrap();
430        assert!(parsed["address"].as_str().unwrap().starts_with("0x742d"));
431    }
432
433    #[test]
434    fn test_format_risk_table_no_recommendations() {
435        let mut assessment = create_test_assessment();
436        assessment.recommendations = vec![];
437        let output = format_risk_report(&assessment, OutputFormat::Table, false);
438        assert!(!output.contains("Recommendations"));
439    }
440
441    #[test]
442    fn test_format_risk_table_many_recommendations() {
443        let mut assessment = create_test_assessment();
444        assessment.recommendations = (0..10).map(|i| format!("Recommendation {}", i)).collect();
445        let output = format_risk_report(&assessment, OutputFormat::Table, false);
446        assert!(output.contains("1."));
447        assert!(output.contains("10."));
448    }
449
450    #[test]
451    fn test_format_risk_markdown_with_evidence() {
452        let mut assessment = create_test_assessment();
453        assessment.factors[0].evidence = vec![
454            "Evidence A".to_string(),
455            "Evidence B".to_string(),
456            "Evidence C".to_string(),
457        ];
458        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
459        assert!(output.contains("Evidence A"));
460        assert!(output.contains("Evidence B"));
461        assert!(output.contains("Evidence C"));
462    }
463
464    #[test]
465    fn test_format_risk_markdown_empty_evidence() {
466        let mut assessment = create_test_assessment();
467        for factor in &mut assessment.factors {
468            factor.evidence = vec![];
469        }
470        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
471        // Should not contain "**Evidence:**" section since evidence is empty
472        assert!(!output.contains("**Evidence:**"));
473    }
474
475    #[test]
476    fn test_format_risk_table_long_factor_name() {
477        let mut assessment = create_test_assessment();
478        assessment.factors[0].name =
479            "A Very Long Factor Name That Exceeds 24 Characters".to_string();
480        let output = format_risk_report(&assessment, OutputFormat::Table, true);
481        // Should be truncated to 24 chars
482        assert!(output.contains("A Very Long Factor Name "));
483    }
484
485    #[test]
486    fn test_all_output_formats_no_panic() {
487        let assessment = create_test_assessment();
488        for format in [
489            OutputFormat::Table,
490            OutputFormat::Json,
491            OutputFormat::Yaml,
492            OutputFormat::Markdown,
493        ] {
494            for detailed in [true, false] {
495                let output = format_risk_report(&assessment, format, detailed);
496                assert!(!output.is_empty());
497            }
498        }
499    }
500
501    #[test]
502    fn test_output_format_default() {
503        let format = OutputFormat::default();
504        assert!(matches!(format, OutputFormat::Table));
505    }
506
507    #[test]
508    fn test_markdown_contains_disclaimer() {
509        let assessment = create_test_assessment();
510        let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
511        assert!(output.contains("generated automatically"));
512    }
513
514    #[test]
515    fn test_format_all_risk_levels() {
516        let mut assessment = create_test_assessment();
517        for (level, emoji) in [
518            (RiskLevel::Low, "🟢"),
519            (RiskLevel::Medium, "🟡"),
520            (RiskLevel::High, "🔴"),
521            (RiskLevel::Critical, "âš«"),
522        ] {
523            assessment.risk_level = level;
524            let output = format_risk_report(&assessment, OutputFormat::Table, false);
525            assert!(output.contains(emoji));
526            let md = format_risk_report(&assessment, OutputFormat::Markdown, false);
527            assert!(md.contains(emoji));
528        }
529    }
530
531    #[test]
532    fn test_format_risk_markdown_all_categories() {
533        let mut assessment = create_test_assessment();
534        assessment.factors = vec![
535            RiskFactor {
536                name: "Behavioral".to_string(),
537                category: RiskCategory::Behavioral,
538                score: 3.0,
539                weight: 0.2,
540                description: "Behavioral analysis".to_string(),
541                evidence: vec!["evidence".to_string()],
542            },
543            RiskFactor {
544                name: "Association".to_string(),
545                category: RiskCategory::Association,
546                score: 4.0,
547                weight: 0.2,
548                description: "Association analysis".to_string(),
549                evidence: vec![],
550            },
551            RiskFactor {
552                name: "Source".to_string(),
553                category: RiskCategory::Source,
554                score: 2.0,
555                weight: 0.2,
556                description: "Source analysis".to_string(),
557                evidence: vec![],
558            },
559            RiskFactor {
560                name: "Destination".to_string(),
561                category: RiskCategory::Destination,
562                score: 1.0,
563                weight: 0.2,
564                description: "Destination analysis".to_string(),
565                evidence: vec![],
566            },
567            RiskFactor {
568                name: "Entity".to_string(),
569                category: RiskCategory::Entity,
570                score: 5.0,
571                weight: 0.2,
572                description: "Entity analysis".to_string(),
573                evidence: vec![],
574            },
575        ];
576        let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
577        assert!(output.contains("Behavioral"));
578        assert!(output.contains("Association"));
579        assert!(output.contains("Source"));
580        assert!(output.contains("Destination"));
581        assert!(output.contains("Entity"));
582    }
583}