Skip to main content

scope/cli/
report.rs

1//! # Report Command
2//!
3//! Batch and combined report generation for multiple addresses or tokens.
4
5use 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/// Report subcommands.
13#[derive(Debug, Subcommand)]
14pub enum ReportCommands {
15    /// Generate a combined report for multiple addresses.
16    ///
17    /// Runs address analysis for each target and outputs a single
18    /// markdown report. Targets can be comma-separated or from a file.
19    Batch(BatchArgs),
20}
21
22#[derive(Debug, Args)]
23pub struct BatchArgs {
24    /// Addresses to analyze (comma-separated).
25    #[arg(long, value_delimiter = ',', value_name = "ADDRESS")]
26    pub addresses: Vec<String>,
27
28    /// File containing addresses (one per line, optionally "address,chain").
29    #[arg(long, value_name = "PATH")]
30    pub from_file: Option<std::path::PathBuf>,
31
32    /// Output report path.
33    #[arg(short, long, required = true, value_name = "PATH")]
34    pub output: std::path::PathBuf,
35
36    /// Default chain for addresses (when not specified per-address).
37    #[arg(short, long, default_value = "ethereum")]
38    pub chain: String,
39
40    /// Include risk assessment per address (uses ETHERSCAN_API_KEY for Ethereum).
41    #[arg(long, default_value_t = false)]
42    pub with_risk: bool,
43}
44
45/// Run the report command.
46pub 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}