use crate::compliance::risk::RiskAssessment;
#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Table,
Json,
Yaml,
Markdown,
}
pub fn format_risk_report(
assessment: &RiskAssessment,
format: OutputFormat,
detailed: bool,
) -> String {
match format {
OutputFormat::Table => format_risk_table(assessment, detailed),
OutputFormat::Json => serde_json::to_string_pretty(assessment).unwrap_or_default(),
OutputFormat::Yaml => serde_saphyr::to_string(assessment).unwrap_or_default(),
OutputFormat::Markdown => format_risk_markdown(assessment, detailed),
}
}
fn format_risk_table(assessment: &RiskAssessment, detailed: bool) -> String {
use crate::display::terminal as t;
let mut output = String::new();
output.push_str(&t::section_header(&format!(
"{} Risk Assessment Report",
assessment.risk_level.emoji()
)));
output.push('\n');
output.push_str(&t::kv_row("Address", &assessment.address));
output.push('\n');
output.push_str(&t::kv_row("Chain", &assessment.chain));
output.push('\n');
output.push_str(&t::score_bar(
"Risk Score",
(assessment.overall_score * 10.0) as u32,
100,
));
output.push('\n');
output.push_str(&t::kv_row(
"Risk Level",
&format!(
"{} {:?}",
assessment.risk_level.emoji(),
assessment.risk_level
),
));
output.push('\n');
output.push_str(&t::kv_row(
"Assessed At",
&assessment
.assessed_at
.format("%Y-%m-%d %H:%M UTC")
.to_string(),
));
output.push('\n');
if detailed {
output.push_str(&t::subsection_header("Risk Factor Breakdown"));
output.push('\n');
let cols = [
t::Col {
label: "Factor",
width: 25,
align: '<',
},
t::Col {
label: "Category",
width: 12,
align: '<',
},
t::Col {
label: "Score",
width: 8,
align: '<',
},
t::Col {
label: "Weight",
width: 8,
align: '<',
},
t::Col {
label: "Weighted",
width: 10,
align: '<',
},
];
output.push_str(&t::table_header(&cols));
output.push('\n');
for factor in &assessment.factors {
let weighted = factor.score * factor.weight;
let factor_name = factor.name.chars().take(24).collect::<String>();
let category_str = format!("{:?}", factor.category)
.chars()
.take(11)
.collect::<String>();
let score_str = format!("{:.1}", factor.score);
let weight_str = format!("{:.0}%", factor.weight * 100.0);
let weighted_str = format!("{:.2}", weighted);
output.push_str(&t::table_row(
&cols,
&[
&factor_name,
&category_str,
&score_str,
&weight_str,
&weighted_str,
],
));
output.push('\n');
}
}
if !assessment.not_assessed.is_empty() {
output.push_str(&t::subsection_header(
"Not Assessed (no data; not in the score)",
));
output.push('\n');
for reason in &assessment.not_assessed {
output.push_str(&t::bullet_row(reason));
output.push('\n');
}
}
if !assessment.recommendations.is_empty() {
output.push_str(&t::subsection_header("Recommendations"));
output.push('\n');
for (i, rec) in assessment.recommendations.iter().enumerate() {
output.push_str(&t::numbered_row(i + 1, rec));
output.push('\n');
}
}
output.push_str(&t::section_footer());
output.push('\n');
output
}
fn format_risk_markdown(assessment: &RiskAssessment, detailed: bool) -> String {
let mut md = String::new();
md.push_str("# Risk Assessment Report\n\n");
md.push_str(&format!("**Address:** `{}`\n\n", assessment.address));
md.push_str(&format!("**Chain:** {}\n\n", assessment.chain));
md.push_str(&format!(
"**Risk Score:** {:.1}/10\n\n",
assessment.overall_score
));
md.push_str(&format!(
"**Risk Level:** {} {:?}\n\n",
assessment.risk_level.emoji(),
assessment.risk_level
));
md.push_str(&format!(
"**Assessed At:** {}\n\n",
assessment.assessed_at.format("%Y-%m-%d %H:%M UTC")
));
if detailed {
md.push_str("## Risk Factor Breakdown\n\n");
md.push_str("| Factor | Category | Score | Weight | Weighted |\n");
md.push_str("|--------|----------|-------|--------|----------|\n");
for factor in &assessment.factors {
let weighted = factor.score * factor.weight;
md.push_str(&format!(
"| {} | {:?} | {:.1} | {:.0}% | {:.2} |\n",
factor.name,
factor.category,
factor.score,
factor.weight * 100.0,
weighted
));
}
md.push('\n');
md.push_str("## Factor Details\n\n");
for factor in &assessment.factors {
md.push_str(&format!("### {} ({:?})\n\n", factor.name, factor.category));
md.push_str(&format!("{}\n\n", factor.description));
if !factor.evidence.is_empty() {
md.push_str("**Evidence:**\n");
for ev in &factor.evidence {
md.push_str(&format!("- {}\n", ev));
}
md.push('\n');
}
}
}
if !assessment.not_assessed.is_empty() {
md.push_str("## Not Assessed\n\nThese factors had no data. They are not in the score.\n\n");
for reason in &assessment.not_assessed {
md.push_str(&format!("- {}\n", reason));
}
md.push('\n');
}
if !assessment.recommendations.is_empty() {
md.push_str("## Recommendations\n\n");
for rec in &assessment.recommendations {
md.push_str(&format!("- {}\n", rec));
}
md.push('\n');
}
md.push_str("---\n\n");
md.push_str("*This report was generated automatically. Always verify data from primary sources before making compliance decisions.*\n");
md
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
use chrono::Utc;
fn create_test_assessment() -> RiskAssessment {
RiskAssessment {
address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
chain: "ethereum".to_string(),
overall_score: 4.5,
risk_level: RiskLevel::Medium,
factors: vec![
RiskFactor {
name: "Behavioral".to_string(),
category: RiskCategory::Behavioral,
score: 3.0,
weight: 0.25,
description: "Test behavioral".to_string(),
evidence: vec!["Evidence 1".to_string()],
},
RiskFactor {
name: "Association".to_string(),
category: RiskCategory::Association,
score: 6.0,
weight: 0.30,
description: "Test association".to_string(),
evidence: vec!["Evidence 2".to_string()],
},
],
not_assessed: Vec::new(),
assessed_at: Utc::now(),
recommendations: vec!["Monitor closely".to_string()],
}
}
#[test]
fn test_format_risk_report_table() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("Risk Assessment Report"));
assert!(output.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
assert!(output.contains("ethereum"));
}
#[test]
fn test_incomplete_report_lists_what_was_not_assessed() {
let _lock = crate::display::terminal::width_lock();
let mut assessment = create_test_assessment();
assessment.risk_level = RiskLevel::Incomplete;
assessment.not_assessed = vec!["Source of Funds: no Etherscan API key".to_string()];
for format in [OutputFormat::Table, OutputFormat::Markdown] {
let output = format_risk_report(&assessment, format, false);
assert!(output.contains("Not Assessed"), "{output}");
assert!(
output.contains("Source of Funds: no Etherscan API key"),
"{output}"
);
assert!(output.contains("Incomplete"), "{output}");
}
let json = format_risk_report(&assessment, OutputFormat::Json, false);
assert!(json.contains("\"not_assessed\""), "{json}");
}
#[test]
fn test_format_risk_report_detailed() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Table, true);
assert!(output.contains("Risk Factor Breakdown"));
assert!(output.contains("Behavioral"));
assert!(output.contains("Association"));
}
#[test]
fn test_format_risk_report_json() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Json, false);
assert!(output.contains("address"));
assert!(output.contains("ethereum"));
assert!(output.contains("overall_score"));
}
#[test]
fn test_format_risk_report_yaml() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Yaml, false);
assert!(output.contains("address:"));
assert!(output.contains("chain:"));
}
#[test]
fn test_format_risk_report_markdown() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
assert!(output.contains("# Risk Assessment Report"));
assert!(output.contains("## Risk Factor Breakdown"));
assert!(output.contains("## Recommendations"));
}
#[test]
fn test_format_low_risk() {
let mut assessment = create_test_assessment();
assessment.risk_level = RiskLevel::Low;
assessment.overall_score = 2.0;
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("🟢"));
}
#[test]
fn test_format_high_risk() {
let mut assessment = create_test_assessment();
assessment.risk_level = RiskLevel::High;
assessment.overall_score = 7.5;
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("🔴"));
}
#[test]
fn test_format_critical_risk() {
let mut assessment = create_test_assessment();
assessment.risk_level = RiskLevel::Critical;
assessment.overall_score = 9.0;
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("âš«"));
}
#[test]
fn test_empty_recommendations() {
let mut assessment = create_test_assessment();
assessment.recommendations = vec![];
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("Risk Assessment Report"));
}
#[test]
fn test_markdown_no_detailed() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
assert!(!output.contains("## Risk Factor Breakdown"));
}
#[test]
fn test_format_risk_report_no_factors() {
let mut assessment = create_test_assessment();
assessment.factors = vec![];
let output = format_risk_report(&assessment, OutputFormat::Table, true);
assert!(output.contains("Risk Assessment Report"));
assert!(output.contains("Risk Factor Breakdown"));
}
#[test]
fn test_format_risk_markdown_no_factors() {
let mut assessment = create_test_assessment();
assessment.factors = vec![];
let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
assert!(output.contains("# Risk Assessment Report"));
assert!(output.contains("## Risk Factor Breakdown"));
}
#[test]
fn test_format_risk_json_roundtrip() {
let assessment = create_test_assessment();
let json = format_risk_report(&assessment, OutputFormat::Json, false);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed["address"],
"0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
);
assert_eq!(parsed["chain"], "ethereum");
}
#[test]
fn test_format_risk_yaml_roundtrip() {
let assessment = create_test_assessment();
let yaml = format_risk_report(&assessment, OutputFormat::Yaml, false);
let parsed: serde_json::Value = serde_saphyr::from_str(&yaml).unwrap();
assert!(parsed["address"].as_str().unwrap().starts_with("0x742d"));
}
#[test]
fn test_format_risk_table_no_recommendations() {
let mut assessment = create_test_assessment();
assessment.recommendations = vec![];
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(!output.contains("Recommendations"));
}
#[test]
fn test_format_risk_table_many_recommendations() {
let mut assessment = create_test_assessment();
assessment.recommendations = (0..10).map(|i| format!("Recommendation {}", i)).collect();
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains("1."));
assert!(output.contains("10."));
}
#[test]
fn test_format_risk_markdown_with_evidence() {
let mut assessment = create_test_assessment();
assessment.factors[0].evidence = vec![
"Evidence A".to_string(),
"Evidence B".to_string(),
"Evidence C".to_string(),
];
let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
assert!(output.contains("Evidence A"));
assert!(output.contains("Evidence B"));
assert!(output.contains("Evidence C"));
}
#[test]
fn test_format_risk_markdown_empty_evidence() {
let mut assessment = create_test_assessment();
for factor in &mut assessment.factors {
factor.evidence = vec![];
}
let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
assert!(!output.contains("**Evidence:**"));
}
#[test]
fn test_format_risk_table_long_factor_name() {
let mut assessment = create_test_assessment();
assessment.factors[0].name =
"A Very Long Factor Name That Exceeds 24 Characters".to_string();
let output = format_risk_report(&assessment, OutputFormat::Table, true);
assert!(output.contains("A Very Long Factor Name "));
}
#[test]
fn test_all_output_formats_no_panic() {
let assessment = create_test_assessment();
for format in [
OutputFormat::Table,
OutputFormat::Json,
OutputFormat::Yaml,
OutputFormat::Markdown,
] {
for detailed in [true, false] {
let output = format_risk_report(&assessment, format, detailed);
assert!(!output.is_empty());
}
}
}
#[test]
fn test_output_format_default() {
let format = OutputFormat::default();
assert!(matches!(format, OutputFormat::Table));
}
#[test]
fn test_markdown_contains_disclaimer() {
let assessment = create_test_assessment();
let output = format_risk_report(&assessment, OutputFormat::Markdown, false);
assert!(output.contains("generated automatically"));
}
#[test]
fn test_format_all_risk_levels() {
let mut assessment = create_test_assessment();
for (level, emoji) in [
(RiskLevel::Low, "🟢"),
(RiskLevel::Medium, "🟡"),
(RiskLevel::High, "🔴"),
(RiskLevel::Critical, "âš«"),
] {
assessment.risk_level = level;
let output = format_risk_report(&assessment, OutputFormat::Table, false);
assert!(output.contains(emoji));
let md = format_risk_report(&assessment, OutputFormat::Markdown, false);
assert!(md.contains(emoji));
}
}
#[test]
fn test_format_risk_markdown_all_categories() {
let mut assessment = create_test_assessment();
assessment.factors = vec![
RiskFactor {
name: "Behavioral".to_string(),
category: RiskCategory::Behavioral,
score: 3.0,
weight: 0.2,
description: "Behavioral analysis".to_string(),
evidence: vec!["evidence".to_string()],
},
RiskFactor {
name: "Association".to_string(),
category: RiskCategory::Association,
score: 4.0,
weight: 0.2,
description: "Association analysis".to_string(),
evidence: vec![],
},
RiskFactor {
name: "Source".to_string(),
category: RiskCategory::Source,
score: 2.0,
weight: 0.2,
description: "Source analysis".to_string(),
evidence: vec![],
},
RiskFactor {
name: "Destination".to_string(),
category: RiskCategory::Destination,
score: 1.0,
weight: 0.2,
description: "Destination analysis".to_string(),
evidence: vec![],
},
RiskFactor {
name: "Entity".to_string(),
category: RiskCategory::Entity,
score: 5.0,
weight: 0.2,
description: "Entity analysis".to_string(),
evidence: vec![],
},
];
let output = format_risk_report(&assessment, OutputFormat::Markdown, true);
assert!(output.contains("Behavioral"));
assert!(output.contains("Association"));
assert!(output.contains("Source"));
assert!(output.contains("Destination"));
assert!(output.contains("Entity"));
}
}