1use crate::chains::{ChainClientFactory, infer_chain_from_address};
6use crate::cli::address::{self, AddressArgs, AddressReport};
7use crate::cli::address_report;
8use crate::config::Config;
9use crate::error::{Result, ScopeError};
10use clap::{Args, Subcommand};
11
12#[derive(Debug, Subcommand)]
14pub enum ReportCommands {
15 Batch(BatchArgs),
20}
21
22#[derive(Debug, Args)]
23pub struct BatchArgs {
24 #[arg(long, value_delimiter = ',', value_name = "ADDRESS")]
26 pub addresses: Vec<String>,
27
28 #[arg(long, value_name = "PATH")]
30 pub from_file: Option<std::path::PathBuf>,
31
32 #[arg(short, long, required = true, value_name = "PATH")]
34 pub output: std::path::PathBuf,
35
36 #[arg(short, long, default_value = "ethereum")]
38 pub chain: String,
39
40 #[arg(long, default_value_t = false)]
42 pub with_risk: bool,
43}
44
45pub async fn run(
47 args: ReportCommands,
48 config: &Config,
49 clients: &dyn ChainClientFactory,
50) -> Result<()> {
51 match args {
52 ReportCommands::Batch(batch_args) => run_batch(batch_args, config, clients).await,
53 }
54}
55
56async fn run_batch(
57 args: BatchArgs,
58 config: &Config,
59 clients: &dyn ChainClientFactory,
60) -> Result<()> {
61 let targets = resolve_targets(&args)?;
62 if targets.is_empty() {
63 return Err(ScopeError::Export(
64 "No addresses to analyze. Use --addresses or --from-file.".to_string(),
65 ));
66 }
67
68 let prog = crate::cli::progress::StepProgress::new(
69 targets.len() as u64,
70 &format!(
71 "Batch report{}",
72 if args.with_risk { " (with risk)" } else { "" }
73 ),
74 );
75 let mut reports = Vec::new();
76 let mut risk_assessments: Vec<Option<crate::compliance::risk::RiskAssessment>> = Vec::new();
77
78 let engine = match crate::compliance::datasource::BlockchainDataClient::from_env_opt() {
79 Some(client) => crate::compliance::risk::RiskEngine::with_data_client(client),
80 None => crate::compliance::risk::RiskEngine::new(),
81 };
82
83 for (address, chain) in &targets {
84 let short_addr = if address.len() > 12 {
85 format!("{}...{}", &address[..6], &address[address.len() - 4..])
86 } else {
87 address.clone()
88 };
89 prog.inc(&short_addr);
90
91 let addr_args = AddressArgs {
92 address: address.clone(),
93 chain: chain.clone(),
94 format: Some(config.output.format),
95 include_txs: true,
96 include_tokens: true,
97 limit: 50,
98 report: None,
99 dossier: false,
100 };
101
102 let client = clients.create_chain_client(chain)?;
103 match address::analyze_address(&addr_args, client.as_ref()).await {
104 Ok(report) => {
105 let risk = if args.with_risk {
106 engine.assess_address(address, chain).await.ok()
107 } else {
108 None
109 };
110 reports.push(report);
111 risk_assessments.push(risk);
112 }
113 Err(e) => {
114 eprintln!("Warning: Failed to analyze {}: {}", address, e);
115 }
116 }
117 }
118
119 prog.finish("All addresses analyzed.");
120 let md = batch_report_to_markdown(&reports, &risk_assessments, args.with_risk);
121 std::fs::write(&args.output, &md)?;
122 println!("Batch report saved to: {}", args.output.display());
123 Ok(())
124}
125
126fn resolve_targets(args: &BatchArgs) -> Result<Vec<(String, String)>> {
127 let mut targets = Vec::new();
128
129 for addr in &args.addresses {
130 let chain = if args.chain == "ethereum" {
131 infer_chain_from_address(addr)
132 .map(String::from)
133 .unwrap_or_else(|| args.chain.clone())
134 } else {
135 args.chain.clone()
136 };
137 targets.push((addr.clone(), chain));
138 }
139
140 if let Some(ref path) = args.from_file {
141 if !path.exists() {
142 return Err(ScopeError::Io(format!(
143 "File not found: {}",
144 path.display()
145 )));
146 }
147 let content = std::fs::read_to_string(path)?;
148 for line in content.lines() {
149 let line = line.trim();
150 if line.is_empty() || line.starts_with('#') {
151 continue;
152 }
153 let (addr, chain) = if let Some((a, c)) = line.split_once(',') {
154 (a.trim().to_string(), c.trim().to_string())
155 } else {
156 (
157 line.to_string(),
158 infer_chain_from_address(line)
159 .map(String::from)
160 .unwrap_or_else(|| args.chain.clone()),
161 )
162 };
163 if !addr.is_empty() {
164 targets.push((addr, chain));
165 }
166 }
167 }
168
169 Ok(targets)
170}
171
172fn batch_report_to_markdown(
173 reports: &[AddressReport],
174 risks: &[Option<crate::compliance::risk::RiskAssessment>],
175 with_risk: bool,
176) -> String {
177 let mut md = format!(
178 "# Batch Address Report{}\n\n\
179 **Generated:** {} \n\
180 **Addresses:** {} \n\n",
181 if with_risk {
182 " (with Risk Assessment)"
183 } else {
184 ""
185 },
186 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
187 reports.len()
188 );
189
190 for (i, report) in reports.iter().enumerate() {
191 md.push_str(&format!(
192 "---\n\n## Address {}: `{}`\n\n",
193 i + 1,
194 report.address
195 ));
196 md.push_str(&address_report::generate_address_report_section(report));
197
198 if with_risk {
199 if let Some(risk) = risks.get(i).and_then(|r| r.as_ref()) {
200 md.push_str("\n### Risk Assessment\n\n");
201 md.push_str(&crate::display::format_risk_report(
202 risk,
203 crate::display::OutputFormat::Markdown,
204 false,
205 ));
206 } else {
207 md.push_str("\n### Risk Assessment\n\n*Risk assessment unavailable for this address/chain.*\n");
208 }
209 }
210 md.push('\n');
211 }
212
213 md.push_str(&crate::display::report::report_footer());
214 md
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use crate::cli::address::{AddressReport, Balance, TokenBalance, TransactionSummary};
221 use tempfile::NamedTempFile;
222
223 #[test]
224 fn test_resolve_targets_addresses_only() {
225 let args = BatchArgs {
226 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
227 from_file: None,
228 output: std::path::PathBuf::from("/tmp/out.md"),
229 chain: "ethereum".to_string(),
230 with_risk: false,
231 };
232 let targets = resolve_targets(&args).unwrap();
233 assert_eq!(targets.len(), 1);
234 assert_eq!(targets[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
235 assert_eq!(targets[0].1, "ethereum");
236 }
237
238 #[test]
239 fn test_resolve_targets_multiple_addresses() {
240 let args = BatchArgs {
241 addresses: vec![
242 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
243 "0x0000000000000000000000000000000000000001".to_string(),
244 ],
245 from_file: None,
246 output: std::path::PathBuf::from("/tmp/out.md"),
247 chain: "ethereum".to_string(),
248 with_risk: false,
249 };
250 let targets = resolve_targets(&args).unwrap();
251 assert_eq!(targets.len(), 2);
252 }
253
254 #[test]
255 fn test_resolve_targets_from_file() {
256 let file = NamedTempFile::new().unwrap();
257 std::fs::write(
258 file.path(),
259 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\n# comment\n\n0x0000000000000000000000000000000000000001",
260 )
261 .unwrap();
262
263 let args = BatchArgs {
264 addresses: vec![],
265 from_file: Some(file.path().to_path_buf()),
266 output: std::path::PathBuf::from("/tmp/out.md"),
267 chain: "ethereum".to_string(),
268 with_risk: false,
269 };
270 let targets = resolve_targets(&args).unwrap();
271 assert_eq!(targets.len(), 2);
272 }
273
274 #[test]
275 fn test_resolve_targets_from_file_with_chain_override() {
276 let file = NamedTempFile::new().unwrap();
277 std::fs::write(
278 file.path(),
279 "0x1234567890123456789012345678901234567890,polygon\n",
280 )
281 .unwrap();
282
283 let args = BatchArgs {
284 addresses: vec![],
285 from_file: Some(file.path().to_path_buf()),
286 output: std::path::PathBuf::from("/tmp/out.md"),
287 chain: "ethereum".to_string(),
288 with_risk: false,
289 };
290 let targets = resolve_targets(&args).unwrap();
291 assert_eq!(targets.len(), 1);
292 assert_eq!(targets[0].1, "polygon");
293 }
294
295 #[test]
296 fn test_resolve_targets_file_not_found() {
297 let args = BatchArgs {
298 addresses: vec![],
299 from_file: Some(std::path::PathBuf::from("/nonexistent/path/12345")),
300 output: std::path::PathBuf::from("/tmp/out.md"),
301 chain: "ethereum".to_string(),
302 with_risk: false,
303 };
304 let result = resolve_targets(&args);
305 assert!(result.is_err());
306 }
307
308 fn minimal_report() -> AddressReport {
309 AddressReport {
310 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
311 chain: "ethereum".to_string(),
312 balance: Balance {
313 raw: "1000000000000000000".to_string(),
314 formatted: "1.0 ETH".to_string(),
315 usd: Some(3500.0),
316 },
317 transaction_count: 42,
318 transactions: None,
319 tokens: None,
320 }
321 }
322
323 #[test]
324 fn test_batch_report_to_markdown_single_report() {
325 let reports = vec![minimal_report()];
326 let risks = vec![None];
327 let md = batch_report_to_markdown(&reports, &risks, false);
328 assert!(md.contains("Batch Address Report"));
329 assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
330 assert!(md.contains("Balance Summary"));
331 assert!(md.contains("1.0 ETH"));
332 assert!(!md.contains("Risk Assessment"));
333 }
334
335 #[test]
336 fn test_batch_report_to_markdown_with_risk_placeholder() {
337 let reports = vec![minimal_report()];
338 let risks = vec![None];
339 let md = batch_report_to_markdown(&reports, &risks, true);
340 assert!(md.contains("Risk Assessment"));
341 assert!(md.contains("unavailable"));
342 }
343
344 #[test]
345 fn test_batch_report_to_markdown_with_transactions_and_tokens() {
346 let mut report = minimal_report();
347 report.transactions = Some(vec![TransactionSummary {
348 hash: "0xabc123".to_string(),
349 block_number: 12345,
350 timestamp: 1700000000,
351 from: "0xfrom".to_string(),
352 to: Some("0xto".to_string()),
353 value: "1 ETH".to_string(),
354 status: true,
355 }]);
356 report.tokens = Some(vec![TokenBalance {
357 contract_address: "0xusdc".to_string(),
358 symbol: "USDC".to_string(),
359 name: "USD Coin".to_string(),
360 decimals: 6,
361 balance: "1000000".to_string(),
362 formatted_balance: "1.0 USDC".to_string(),
363 }]);
364
365 let reports = vec![report];
366 let risks = vec![None];
367 let md = batch_report_to_markdown(&reports, &risks, false);
368 assert!(md.contains("Recent Transactions"));
369 assert!(md.contains("Token Balances"));
370 assert!(md.contains("USDC"));
371 }
372
373 #[test]
374 fn test_batch_args_debug() {
375 let args = BatchArgs {
376 addresses: vec!["0x123".to_string(), "0x456".to_string()],
377 from_file: None,
378 output: std::path::PathBuf::from("/tmp/report.md"),
379 chain: "ethereum".to_string(),
380 with_risk: false,
381 };
382 let debug = format!("{:?}", args);
383 assert!(debug.contains("BatchArgs"));
384 assert!(debug.contains("0x123"));
385 }
386
387 #[test]
388 fn test_batch_args_with_risk() {
389 let args = BatchArgs {
390 addresses: vec![],
391 from_file: Some(std::path::PathBuf::from("addrs.txt")),
392 output: std::path::PathBuf::from("/tmp/report.md"),
393 chain: "polygon".to_string(),
394 with_risk: true,
395 };
396 assert!(args.with_risk);
397 assert_eq!(args.chain, "polygon");
398 assert!(args.from_file.is_some());
399 }
400
401 #[test]
402 fn test_report_commands_debug() {
403 let cmd = ReportCommands::Batch(BatchArgs {
404 addresses: vec!["0xabc".to_string()],
405 from_file: None,
406 output: std::path::PathBuf::from("out.md"),
407 chain: "ethereum".to_string(),
408 with_risk: false,
409 });
410 let debug = format!("{:?}", cmd);
411 assert!(debug.contains("Batch"));
412 }
413
414 #[test]
415 fn test_batch_report_to_markdown_with_risk_data() {
416 use crate::compliance::risk::{RiskAssessment, RiskFactor, RiskLevel};
417
418 let reports = vec![minimal_report()];
419 let risk = RiskAssessment {
420 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
421 chain: "ethereum".to_string(),
422 overall_score: 3.5,
423 risk_level: RiskLevel::Low,
424 factors: vec![RiskFactor {
425 name: "Address Age".to_string(),
426 category: crate::compliance::risk::RiskCategory::Behavioral,
427 score: 2.0,
428 weight: 1.0,
429 description: "Address is well-established".to_string(),
430 evidence: vec!["Known address".to_string()],
431 }],
432 recommendations: vec!["Continue monitoring".to_string()],
433 assessed_at: chrono::Utc::now(),
434 };
435 let risks = vec![Some(risk)];
436 let md = batch_report_to_markdown(&reports, &risks, true);
437 assert!(md.contains("Risk Assessment"));
438 assert!(md.contains("with Risk Assessment"));
439 assert!(!md.contains("unavailable"));
441 }
442
443 #[test]
444 fn test_batch_report_to_markdown_multiple_reports() {
445 let mut report1 = minimal_report();
446 report1.address = "0xaaa".to_string();
447 let mut report2 = minimal_report();
448 report2.address = "0xbbb".to_string();
449 report2.chain = "polygon".to_string();
450
451 let reports = vec![report1, report2];
452 let risks = vec![None, None];
453 let md = batch_report_to_markdown(&reports, &risks, false);
454 assert!(md.contains("0xaaa"));
455 assert!(md.contains("0xbbb"));
456 assert!(md.contains("Address 1"));
457 assert!(md.contains("Address 2"));
458 }
459
460 #[test]
461 fn test_resolve_targets_non_default_chain() {
462 let args = BatchArgs {
463 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
464 from_file: None,
465 output: std::path::PathBuf::from("/tmp/out.md"),
466 chain: "polygon".to_string(),
467 with_risk: false,
468 };
469 let targets = resolve_targets(&args).unwrap();
470 assert_eq!(targets.len(), 1);
471 assert_eq!(targets[0].1, "polygon");
473 }
474
475 #[test]
476 fn test_resolve_targets_empty() {
477 let args = BatchArgs {
478 addresses: vec![],
479 from_file: None,
480 output: std::path::PathBuf::from("/tmp/out.md"),
481 chain: "ethereum".to_string(),
482 with_risk: false,
483 };
484 let targets = resolve_targets(&args).unwrap();
485 assert_eq!(targets.len(), 0);
486 }
487
488 #[tokio::test]
489 async fn test_run_batch_with_mock_factory() {
490 use crate::chains::mocks::MockClientFactory;
491
492 let temp_dir = tempfile::tempdir().unwrap();
493 let output_path = temp_dir.path().join("batch_report.md");
494
495 let args = BatchArgs {
496 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
497 from_file: None,
498 output: output_path.clone(),
499 chain: "ethereum".to_string(),
500 with_risk: false,
501 };
502
503 let config = Config::default();
504 let factory = MockClientFactory::new();
505
506 let result = run_batch(args, &config, &factory).await;
507 assert!(result.is_ok());
508
509 assert!(output_path.exists());
511 let content = std::fs::read_to_string(&output_path).unwrap();
512 assert!(content.contains("Batch Address Report"));
513 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
514 }
515
516 #[tokio::test]
517 async fn test_run_batch_with_risk() {
518 use crate::chains::mocks::MockClientFactory;
519
520 let temp_dir = tempfile::tempdir().unwrap();
521 let output_path = temp_dir.path().join("batch_risk_report.md");
522
523 let args = BatchArgs {
524 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
525 from_file: None,
526 output: output_path.clone(),
527 chain: "ethereum".to_string(),
528 with_risk: true,
529 };
530
531 let config = Config::default();
532 let factory = MockClientFactory::new();
533
534 let result = run_batch(args, &config, &factory).await;
535 assert!(result.is_ok());
536 let content = std::fs::read_to_string(&output_path).unwrap();
537 assert!(content.contains("Risk Assessment"));
538 }
539
540 #[tokio::test]
541 async fn test_run_batch_empty_targets() {
542 use crate::chains::mocks::MockClientFactory;
543
544 let temp_dir = tempfile::tempdir().unwrap();
545 let output_path = temp_dir.path().join("empty.md");
546
547 let args = BatchArgs {
548 addresses: vec![],
549 from_file: None,
550 output: output_path,
551 chain: "ethereum".to_string(),
552 with_risk: false,
553 };
554
555 let config = Config::default();
556 let factory = MockClientFactory::new();
557
558 let result = run_batch(args, &config, &factory).await;
559 assert!(result.is_err());
560 let err_msg = result.unwrap_err().to_string();
561 assert!(err_msg.contains("No addresses"));
562 }
563
564 #[tokio::test]
565 async fn test_run_dispatch() {
566 use crate::chains::mocks::MockClientFactory;
567
568 let temp_dir = tempfile::tempdir().unwrap();
569 let output_path = temp_dir.path().join("dispatch_report.md");
570
571 let args = ReportCommands::Batch(BatchArgs {
572 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
573 from_file: None,
574 output: output_path.clone(),
575 chain: "ethereum".to_string(),
576 with_risk: false,
577 });
578
579 let config = Config::default();
580 let factory = MockClientFactory::new();
581
582 let result = run(args, &config, &factory).await;
583 assert!(result.is_ok());
584 }
585}