1use crate::compliance::risk::RiskAssessment;
4#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
9pub enum OutputFormat {
10 #[default]
11 Table,
12 Json,
13 Yaml,
14 Markdown,
15}
16
17pub 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
31fn format_risk_table(assessment: &RiskAssessment, detailed: bool) -> String {
33 use crate::display::terminal as t;
34 let mut output = String::new();
35
36 output.push_str(&t::section_header(&format!(
38 "{} Risk Assessment Report",
39 assessment.risk_level.emoji()
40 )));
41 output.push('\n');
42
43 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 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 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
161fn 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 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 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 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 assert!(!output.contains("## Risk Factor Breakdown"));
384 }
385
386 #[test]
391 fn test_format_risk_report_no_factors() {
392 let mut assessment = create_test_assessment();
393 assessment.factors = vec![];
394 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 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 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 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 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}