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_saphyr::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 _lock = crate::display::terminal::width_lock();
288 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 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 assert!(!output.contains("## Risk Factor Breakdown"));
386 }
387
388 #[test]
393 fn test_format_risk_report_no_factors() {
394 let mut assessment = create_test_assessment();
395 assessment.factors = vec![];
396 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 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 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 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 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}