Skip to main content

scope_cli/cli/
address_report.rs

1//! # Address Report Generator
2//!
3//! Generates markdown reports for blockchain address analysis.
4
5use super::address::AddressReport;
6use chrono::{DateTime, Utc};
7use scope::display::report::{report_footer, save_report};
8use scope::error::Result;
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: &scope::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(&scope::display::format_risk_report(
44        risk,
45        scope::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 = scope::display::text::abbreviate(&tx.hash);
115                let to = tx.to.as_deref().unwrap_or("-");
116                let status = if tx.status { "✓" } else { "✗" };
117                s.push_str(&format!(
118                    "| `{}` | {} | {} | `{}` | `{}` | {} | {} |\n",
119                    hash_short, tx.block_number, ts, tx.from, to, tx.value, status
120                ));
121            }
122            if txs.len() > 20 {
123                s.push_str(&format!("\n*Showing 20 of {} transactions*\n", txs.len()));
124            }
125        }
126        _ => s.push_str("*No transaction data available*\n"),
127    }
128    s
129}
130
131fn report_tokens(report: &AddressReport) -> String {
132    let mut s = String::from("## Token Balances\n\n");
133    match &report.tokens {
134        Some(tokens) if !tokens.is_empty() => {
135            s.push_str("| Token | Contract | Balance |\n");
136            s.push_str("|-------|----------|--------|\n");
137            for t in tokens {
138                s.push_str(&format!(
139                    "| {} ({}) | `{}` | {} |\n",
140                    t.name, t.symbol, t.contract_address, t.formatted_balance
141                ));
142            }
143        }
144        _ => s.push_str("*No token balance data available*\n"),
145    }
146    s
147}
148
149fn capitalize_chain(chain: &str) -> String {
150    let mut chars = chain.chars();
151    match chars.next() {
152        None => String::new(),
153        Some(c) => c.to_uppercase().chain(chars).collect(),
154    }
155}
156
157/// Saves an address report to a file.
158pub fn save_address_report(report: &str, path: impl AsRef<Path>) -> Result<()> {
159    save_report(report, path)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::cli::address::{AddressReport, Balance, TokenBalance, TransactionSummary};
166
167    fn minimal_report() -> AddressReport {
168        AddressReport {
169            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
170            chain: "ethereum".to_string(),
171            balance: Balance {
172                raw: "1000000000000000000".to_string(),
173                formatted: "1.0 ETH".to_string(),
174                usd: Some(3500.0),
175            },
176            transaction_count: 10,
177            transactions: None,
178            tokens: None,
179        }
180    }
181
182    #[test]
183    fn test_generate_address_report_section_minimal() {
184        let report = minimal_report();
185        let md = generate_address_report_section(&report);
186        assert!(md.contains("Balance Summary"));
187        assert!(md.contains("1.0 ETH"));
188        assert!(md.contains("$3500.00"));
189        assert!(md.contains("Transaction Count"));
190        assert!(md.contains("No transaction data available"));
191        assert!(md.contains("No token balance data available"));
192    }
193
194    #[test]
195    fn test_generate_address_report_section_with_transactions() {
196        let mut report = minimal_report();
197        report.transactions = Some(vec![TransactionSummary {
198            hash: "0xabc123def456".to_string(),
199            block_number: 12345,
200            timestamp: 1700000000,
201            from: "0xfrom123".to_string(),
202            to: Some("0xto456".to_string()),
203            value: "1 ETH".to_string(),
204            status: true,
205        }]);
206        let md = generate_address_report_section(&report);
207        assert!(md.contains("Recent Transactions"));
208        assert!(md.contains("0xabc1"));
209        assert!(md.contains("12345"));
210    }
211
212    #[test]
213    fn test_generate_address_report_section_with_tokens() {
214        let mut report = minimal_report();
215        report.tokens = Some(vec![TokenBalance {
216            contract_address: "0xusdc".to_string(),
217            symbol: "USDC".to_string(),
218            name: "USD Coin".to_string(),
219            decimals: 6,
220            balance: "1000000".to_string(),
221            formatted_balance: "1.0 USDC".to_string(),
222        }]);
223        let md = generate_address_report_section(&report);
224        assert!(md.contains("Token Balances"));
225        assert!(md.contains("USDC"));
226        assert!(md.contains("USD Coin"));
227    }
228
229    #[test]
230    fn test_generate_address_report_full() {
231        let report = minimal_report();
232        let md = generate_address_report(&report);
233        assert!(md.contains("Address Analysis Report"));
234        assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
235        assert!(md.contains("Ethereum"));
236    }
237
238    #[test]
239    fn test_capitalize_chain_empty() {
240        // Covers line 156: capitalize_chain with empty string → None branch
241        assert_eq!(capitalize_chain(""), "");
242    }
243
244    #[test]
245    fn test_save_address_report_to_file() {
246        // Covers lines 162-163: save_address_report delegates to save_report
247        let dir = tempfile::tempdir().unwrap();
248        let path = dir.path().join("test_addr_report.md");
249        let result = save_address_report("# Test Report\n\nSome content", &path);
250        assert!(result.is_ok());
251        assert!(path.exists());
252        let contents = std::fs::read_to_string(&path).unwrap();
253        assert!(contents.contains("Test Report"));
254    }
255
256    #[test]
257    fn test_report_transactions_more_than_20() {
258        // Covers line 127: "Showing 20 of N transactions" when > 20 txs
259        let mut report = minimal_report();
260        let txs: Vec<TransactionSummary> = (0..25)
261            .map(|i| TransactionSummary {
262                hash: format!("0x{:064x}", i),
263                block_number: 12345 + i,
264                timestamp: 1700000000 + i * 60,
265                from: "0xfrom".to_string(),
266                to: Some("0xto".to_string()),
267                value: "0.1 ETH".to_string(),
268                status: true,
269            })
270            .collect();
271        report.transactions = Some(txs);
272        let md = generate_address_report_section(&report);
273        assert!(md.contains("Showing 20 of 25 transactions"));
274    }
275
276    #[test]
277    fn test_generate_dossier_report() {
278        use chrono::Utc;
279        use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
280
281        let report = minimal_report();
282        let risk = RiskAssessment {
283            address: report.address.clone(),
284            chain: report.chain.clone(),
285            overall_score: 3.5,
286            risk_level: RiskLevel::Low,
287            factors: vec![RiskFactor {
288                name: "Test factor".to_string(),
289                category: RiskCategory::Behavioral,
290                score: 3.0,
291                weight: 0.5,
292                description: "A test risk factor".to_string(),
293                evidence: vec!["evidence".to_string()],
294            }],
295            not_assessed: Vec::new(),
296            assessed_at: Utc::now(),
297            recommendations: vec!["Be cautious".to_string()],
298        };
299        let md = generate_dossier_report(&report, &risk);
300        assert!(md.contains("Wallet Dossier"));
301        assert!(md.contains("Risk Assessment"));
302        assert!(md.contains("Test factor"));
303    }
304}