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