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 println!(
69 "Generating batch report for {} address(es){}...",
70 targets.len(),
71 if args.with_risk { " (with risk)" } else { "" }
72 );
73 let mut reports = Vec::new();
74 let mut risk_assessments: Vec<Option<crate::compliance::risk::RiskAssessment>> = Vec::new();
75
76 let engine = match crate::compliance::datasource::BlockchainDataClient::from_env_opt() {
77 Some(client) => crate::compliance::risk::RiskEngine::with_data_client(client),
78 None => crate::compliance::risk::RiskEngine::new(),
79 };
80
81 for (address, chain) in &targets {
82 let addr_args = AddressArgs {
83 address: address.clone(),
84 chain: chain.clone(),
85 format: Some(config.output.format),
86 include_txs: true,
87 include_tokens: true,
88 limit: 50,
89 report: None,
90 dossier: false,
91 };
92
93 let client = clients.create_chain_client(chain)?;
94 match address::analyze_address(&addr_args, client.as_ref()).await {
95 Ok(report) => {
96 let risk = if args.with_risk {
97 engine.assess_address(address, chain).await.ok()
98 } else {
99 None
100 };
101 reports.push(report);
102 risk_assessments.push(risk);
103 }
104 Err(e) => {
105 eprintln!("Warning: Failed to analyze {}: {}", address, e);
106 }
107 }
108 }
109
110 let md = batch_report_to_markdown(&reports, &risk_assessments, args.with_risk);
111 std::fs::write(&args.output, &md)?;
112 println!("\nBatch report saved to: {}", args.output.display());
113 Ok(())
114}
115
116fn resolve_targets(args: &BatchArgs) -> Result<Vec<(String, String)>> {
117 let mut targets = Vec::new();
118
119 for addr in &args.addresses {
120 let chain = if args.chain == "ethereum" {
121 infer_chain_from_address(addr)
122 .map(String::from)
123 .unwrap_or_else(|| args.chain.clone())
124 } else {
125 args.chain.clone()
126 };
127 targets.push((addr.clone(), chain));
128 }
129
130 if let Some(ref path) = args.from_file {
131 if !path.exists() {
132 return Err(ScopeError::Io(format!(
133 "File not found: {}",
134 path.display()
135 )));
136 }
137 let content = std::fs::read_to_string(path)?;
138 for line in content.lines() {
139 let line = line.trim();
140 if line.is_empty() || line.starts_with('#') {
141 continue;
142 }
143 let (addr, chain) = if let Some((a, c)) = line.split_once(',') {
144 (a.trim().to_string(), c.trim().to_string())
145 } else {
146 (
147 line.to_string(),
148 infer_chain_from_address(line)
149 .map(String::from)
150 .unwrap_or_else(|| args.chain.clone()),
151 )
152 };
153 if !addr.is_empty() {
154 targets.push((addr, chain));
155 }
156 }
157 }
158
159 Ok(targets)
160}
161
162fn batch_report_to_markdown(
163 reports: &[AddressReport],
164 risks: &[Option<crate::compliance::risk::RiskAssessment>],
165 with_risk: bool,
166) -> String {
167 let mut md = format!(
168 "# Batch Address Report{}\n\n\
169 **Generated:** {} \n\
170 **Addresses:** {} \n\n",
171 if with_risk {
172 " (with Risk Assessment)"
173 } else {
174 ""
175 },
176 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
177 reports.len()
178 );
179
180 for (i, report) in reports.iter().enumerate() {
181 md.push_str(&format!(
182 "---\n\n## Address {}: `{}`\n\n",
183 i + 1,
184 report.address
185 ));
186 md.push_str(&address_report::generate_address_report_section(report));
187
188 if with_risk {
189 if let Some(risk) = risks.get(i).and_then(|r| r.as_ref()) {
190 md.push_str("\n### Risk Assessment\n\n");
191 md.push_str(&crate::display::format_risk_report(
192 risk,
193 crate::display::OutputFormat::Markdown,
194 false,
195 ));
196 } else {
197 md.push_str("\n### Risk Assessment\n\n*Risk assessment unavailable for this address/chain.*\n");
198 }
199 }
200 md.push('\n');
201 }
202
203 md.push_str(&crate::display::report::report_footer());
204 md
205}