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.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
150fn 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 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 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 assert!(!output.contains("## Risk Factor Breakdown"));
345 }
346
347 #[test]
352 fn test_format_risk_report_no_factors() {
353 let mut assessment = create_test_assessment();
354 assessment.factors = vec![];
355 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 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 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 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 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}