Skip to main content

scope/cli/
address_report.rs

1//! # Address Report Generator
2//!
3//! Generates markdown reports for blockchain address analysis.
4
5use super::address::AddressReport;
6use crate::display::report::{report_footer, save_report};
7use crate::error::Result;
8use chrono::{DateTime, Utc};
9use std::path::Path;
10
11/// Generates a markdown report from an address analysis.
12pub fn generate_address_report(report: &AddressReport) -> String {
13    generate_address_report_core(report, true, true)
14}
15
16/// Generates report content without top-level header or footer (for batch reports).
17pub fn generate_address_report_section(report: &AddressReport) -> String {
18    generate_address_report_core(report, false, false)
19}
20
21/// Generates a combined dossier report: address analysis + risk assessment.
22/// Used when `scope address --dossier` is run with `--report`.
23pub fn generate_dossier_report(
24    report: &AddressReport,
25    risk: &crate::compliance::risk::RiskAssessment,
26) -> String {
27    let mut md = String::new();
28    md.push_str("# Wallet Dossier\n\n");
29    md.push_str(&format!(
30        "**Address:** `{}`  \n**Chain:** {}  \n**Generated:** {}  \n\n",
31        report.address,
32        capitalize_chain(&report.chain),
33        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
34    ));
35    md.push_str("---\n\n");
36    md.push_str(&report_balance(report));
37    md.push_str("\n---\n\n");
38    md.push_str(&report_transactions(report));
39    md.push_str("\n---\n\n");
40    md.push_str(&report_tokens(report));
41    md.push_str("\n---\n\n");
42    md.push_str("## Risk Assessment\n\n");
43    md.push_str(&crate::display::format_risk_report(
44        risk,
45        crate::display::OutputFormat::Markdown,
46        true,
47    ));
48    md.push_str(&report_footer());
49    md
50}
51
52fn generate_address_report_core(
53    report: &AddressReport,
54    include_header: bool,
55    include_footer: bool,
56) -> String {
57    let mut md = String::new();
58
59    if include_header {
60        md.push_str(&report_header(report));
61        md.push_str("\n---\n\n");
62    }
63    md.push_str(&report_balance(report));
64    md.push_str("\n---\n\n");
65    md.push_str(&report_transactions(report));
66    md.push_str("\n---\n\n");
67    md.push_str(&report_tokens(report));
68    if include_footer {
69        md.push_str(&report_footer());
70    }
71
72    md
73}
74
75fn report_header(report: &AddressReport) -> String {
76    format!(
77        "# Address Analysis Report\n\n\
78        **Address:** `{}`  \n\
79        **Chain:** {}  \n\
80        **Generated:** {}  \n",
81        report.address,
82        capitalize_chain(&report.chain),
83        Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
84    )
85}
86
87fn report_balance(report: &AddressReport) -> String {
88    let mut s = String::from("## Balance Summary\n\n");
89    s.push_str("| Metric | Value |\n|--------|-------|\n");
90    s.push_str(&format!(
91        "| Native Balance | {} |\n",
92        report.balance.formatted
93    ));
94    if let Some(usd) = report.balance.usd {
95        s.push_str(&format!("| USD Value | ${:.2} |\n", usd));
96    }
97    s.push_str(&format!(
98        "| Transaction Count | {} |\n",
99        report.transaction_count
100    ));
101    s
102}
103
104fn report_transactions(report: &AddressReport) -> String {
105    let mut s = String::from("## Recent Transactions\n\n");
106    match &report.transactions {
107        Some(txs) if !txs.is_empty() => {
108            s.push_str("| Hash | Block | Time | From | To | Value | Status |\n");
109            s.push_str("|------|-------|------|------|-----|-------|--------|\n");
110            for tx in txs.iter().take(20) {
111                let ts = DateTime::<Utc>::from_timestamp(tx.timestamp as i64, 0)
112                    .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
113                    .unwrap_or_else(|| "-".to_string());
114                let hash_short = if tx.hash.len() > 10 {
115                    format!("{}...{}", &tx.hash[..6], &tx.hash[tx.hash.len() - 4..])
116                } else {
117                    tx.hash.clone()
118                };
119                let to = tx.to.as_deref().unwrap_or("-");
120                let status = if tx.status { "✓" } else { "✗" };
121                s.push_str(&format!(
122                    "| `{}` | {} | {} | `{}` | `{}` | {} | {} |\n",
123                    hash_short, tx.block_number, ts, tx.from, to, tx.value, status
124                ));
125            }
126            if txs.len() > 20 {
127                s.push_str(&format!("\n*Showing 20 of {} transactions*\n", txs.len()));
128            }
129        }
130        _ => s.push_str("*No transaction data available*\n"),
131    }
132    s
133}
134
135fn report_tokens(report: &AddressReport) -> String {
136    let mut s = String::from("## Token Balances\n\n");
137    match &report.tokens {
138        Some(tokens) if !tokens.is_empty() => {
139            s.push_str("| Token | Contract | Balance |\n");
140            s.push_str("|-------|----------|--------|\n");
141            for t in tokens {
142                s.push_str(&format!(
143                    "| {} ({}) | `{}` | {} |\n",
144                    t.name, t.symbol, t.contract_address, t.formatted_balance
145                ));
146            }
147        }
148        _ => s.push_str("*No token balance data available*\n"),
149    }
150    s
151}
152
153fn capitalize_chain(chain: &str) -> String {
154    let mut chars = chain.chars();
155    match chars.next() {
156        None => String::new(),
157        Some(c) => c.to_uppercase().chain(chars).collect(),
158    }
159}
160
161/// Saves an address report to a file.
162pub fn save_address_report(report: &str, path: impl AsRef<Path>) -> Result<()> {
163    save_report(report, path)
164}