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    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        // Should contain actual risk data, not "unavailable"
440        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        // When chain is not "ethereum", it uses the provided chain directly
472        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        // Verify the report was written
510        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}