1use super::address::AddressReport;
6use chrono::{DateTime, Utc};
7use scope::display::report::{report_footer, save_report};
8use scope::error::Result;
9use std::path::Path;
10
11pub fn generate_address_report(report: &AddressReport) -> String {
13 generate_address_report_core(report, true, true)
14}
15
16pub fn generate_address_report_section(report: &AddressReport) -> String {
18 generate_address_report_core(report, false, false)
19}
20
21pub 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
157pub 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 assert_eq!(capitalize_chain(""), "");
242 }
243
244 #[test]
245 fn test_save_address_report_to_file() {
246 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 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}