Skip to main content

scope_cli/cli/
report.rs

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