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)]
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    /// Addresses to analyze (comma-separated).
29    #[arg(long, value_delimiter = ',', value_name = "ADDRESS")]
30    pub addresses: Vec<String>,
31
32    /// File containing addresses (one per line, optionally "address,chain").
33    #[arg(long, value_name = "PATH")]
34    pub from_file: Option<std::path::PathBuf>,
35
36    /// Output report path.
37    #[arg(short, long, required = true, value_name = "PATH")]
38    pub output: std::path::PathBuf,
39
40    /// Default chain for addresses (when not specified per-address).
41    #[arg(short, long, default_value = "ethereum")]
42    pub chain: String,
43
44    /// Include risk assessment per address (uses ETHERSCAN_API_KEY for Ethereum).
45    #[arg(long, default_value_t = false)]
46    pub with_risk: bool,
47}
48
49/// Run the report command.
50pub 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        // Should contain actual risk data, not "unavailable"
444        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        // When chain is not "ethereum", it uses the provided chain directly
476        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        // Verify the report was written
514        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}