Skip to main content

scope/cli/
export.rs

1//! # Export Command
2//!
3//! This module implements the `bca export` command for exporting
4//! analysis data to various formats (JSON, CSV).
5//!
6//! ## Usage
7//!
8//! ```bash
9//! # Export address history to JSON
10//! bca export --address 0x742d... --output history.json
11//!
12//! # Export to CSV
13//! bca export --address 0x742d... --output history.csv --format csv
14//!
15//! # Export portfolio data
16//! bca export --portfolio --output portfolio.json
17//! ```
18
19use crate::chains::{ChainClientFactory, infer_chain_from_address};
20use crate::config::{Config, OutputFormat};
21use crate::error::{Result, ScopeError};
22use clap::Args;
23use std::path::PathBuf;
24
25/// Arguments for the export command.
26#[derive(Debug, Clone, Args)]
27pub struct ExportArgs {
28    /// Address to export data for.
29    #[arg(short, long, value_name = "ADDRESS", group = "source")]
30    pub address: Option<String>,
31
32    /// Export portfolio data.
33    #[arg(short, long, group = "source")]
34    pub portfolio: bool,
35
36    /// Output file path.
37    #[arg(short, long, value_name = "PATH")]
38    pub output: PathBuf,
39
40    /// Output format (auto-detected from extension if not specified).
41    #[arg(short, long, value_name = "FORMAT")]
42    pub format: Option<OutputFormat>,
43
44    /// Target blockchain network (for address export).
45    #[arg(short, long, default_value = "ethereum")]
46    pub chain: String,
47
48    /// Start date for transaction history (YYYY-MM-DD).
49    #[arg(long, value_name = "DATE")]
50    pub from: Option<String>,
51
52    /// End date for transaction history (YYYY-MM-DD).
53    #[arg(long, value_name = "DATE")]
54    pub to: Option<String>,
55
56    /// Maximum number of transactions to export.
57    #[arg(long, default_value = "1000")]
58    pub limit: u32,
59}
60
61/// Data export report.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ExportReport {
64    /// Export type (address, portfolio).
65    pub export_type: String,
66
67    /// Number of records exported.
68    pub record_count: usize,
69
70    /// Output file path.
71    pub output_path: String,
72
73    /// Export format.
74    pub format: String,
75
76    /// Export timestamp.
77    pub exported_at: u64,
78}
79
80/// Executes the export command.
81///
82/// # Arguments
83///
84/// * `args` - The parsed command arguments
85/// * `config` - Application configuration
86///
87/// # Returns
88///
89/// Returns `Ok(())` on success, or an error if the export fails.
90///
91/// # Errors
92///
93/// Returns [`ScopeError::Export`] if the export operation fails.
94/// Returns [`ScopeError::Io`] if file operations fail.
95pub async fn run(
96    args: ExportArgs,
97    config: &Config,
98    clients: &dyn ChainClientFactory,
99) -> Result<()> {
100    // Determine format from argument or file extension
101    let format = args.format.unwrap_or_else(|| detect_format(&args.output));
102
103    tracing::info!(
104        output = %args.output.display(),
105        format = %format,
106        "Starting export"
107    );
108
109    if args.portfolio {
110        export_portfolio(&args, format, config).await
111    } else if let Some(ref address) = args.address {
112        export_address(address, &args, format, clients).await
113    } else {
114        Err(ScopeError::Export(
115            "Must specify either --address or --portfolio".to_string(),
116        ))
117    }
118}
119
120/// Detects output format from file extension.
121fn detect_format(path: &std::path::Path) -> OutputFormat {
122    match path.extension().and_then(|e| e.to_str()) {
123        Some("json") => OutputFormat::Json,
124        Some("csv") => OutputFormat::Csv,
125        _ => OutputFormat::Json, // Default to JSON
126    }
127}
128
129/// Exports portfolio data.
130async fn export_portfolio(args: &ExportArgs, format: OutputFormat, config: &Config) -> Result<()> {
131    use crate::cli::portfolio::Portfolio;
132
133    let data_dir = config.data_dir();
134    let portfolio = Portfolio::load(&data_dir)?;
135
136    let content = match format {
137        OutputFormat::Json => serde_json::to_string_pretty(&portfolio)?,
138        OutputFormat::Csv => {
139            let mut csv = String::from("address,label,chain,tags,added_at\n");
140            for addr in &portfolio.addresses {
141                csv.push_str(&format!(
142                    "{},{},{},{},{}\n",
143                    addr.address,
144                    addr.label.as_deref().unwrap_or(""),
145                    addr.chain,
146                    addr.tags.join(";"),
147                    addr.added_at
148                ));
149            }
150            csv
151        }
152        OutputFormat::Table => {
153            return Err(ScopeError::Export(
154                "Table format not supported for file export".to_string(),
155            ));
156        }
157    };
158
159    std::fs::write(&args.output, &content)?;
160
161    let report = ExportReport {
162        export_type: "portfolio".to_string(),
163        record_count: portfolio.addresses.len(),
164        output_path: args.output.display().to_string(),
165        format: format.to_string(),
166        exported_at: std::time::SystemTime::now()
167            .duration_since(std::time::UNIX_EPOCH)
168            .unwrap_or_default()
169            .as_secs(),
170    };
171
172    println!(
173        "Exported {} portfolio addresses to {}",
174        report.record_count, report.output_path
175    );
176
177    Ok(())
178}
179
180/// Exports address transaction history.
181async fn export_address(
182    address: &str,
183    args: &ExportArgs,
184    format: OutputFormat,
185    clients: &dyn ChainClientFactory,
186) -> Result<()> {
187    // Auto-detect chain if default
188    let chain = if args.chain == "ethereum" {
189        infer_chain_from_address(address)
190            .unwrap_or("ethereum")
191            .to_string()
192    } else {
193        args.chain.clone()
194    };
195
196    tracing::info!(
197        address = %address,
198        chain = %chain,
199        "Exporting address data"
200    );
201
202    println!("Fetching transactions for {} on {}...", address, chain);
203
204    // Fetch real transaction history
205    let client = clients.create_chain_client(&chain)?;
206    let chain_txs = client.get_transactions(address, args.limit).await?;
207
208    // Apply date filtering if --from / --to are provided
209    let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
210    let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
211
212    let transactions: Vec<TransactionExport> = chain_txs
213        .into_iter()
214        .filter(|tx| {
215            let ts = tx.timestamp.unwrap_or(0);
216            if let Some(from) = from_ts
217                && ts < from
218            {
219                return false;
220            }
221            if let Some(to) = to_ts
222                && ts > to
223            {
224                return false;
225            }
226            true
227        })
228        .map(|tx| TransactionExport {
229            hash: tx.hash,
230            block_number: tx.block_number.unwrap_or(0),
231            timestamp: tx.timestamp.unwrap_or(0),
232            from: tx.from,
233            to: tx.to,
234            value: tx.value,
235            gas_used: tx.gas_used.unwrap_or(0),
236            status: tx.status.unwrap_or(true),
237        })
238        .collect();
239
240    let content = match format {
241        OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
242            address: address.to_string(),
243            chain: chain.clone(),
244            transactions: transactions.clone(),
245            exported_at: std::time::SystemTime::now()
246                .duration_since(std::time::UNIX_EPOCH)
247                .unwrap_or_default()
248                .as_secs(),
249        })?,
250        OutputFormat::Csv => {
251            let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
252            for tx in &transactions {
253                csv.push_str(&format!(
254                    "{},{},{},{},{},{},{},{}\n",
255                    tx.hash,
256                    tx.block_number,
257                    tx.timestamp,
258                    tx.from,
259                    tx.to.as_deref().unwrap_or(""),
260                    tx.value,
261                    tx.gas_used,
262                    tx.status
263                ));
264            }
265            csv
266        }
267        OutputFormat::Table => {
268            return Err(ScopeError::Export(
269                "Table format not supported for file export".to_string(),
270            ));
271        }
272    };
273
274    std::fs::write(&args.output, &content)?;
275
276    let report = ExportReport {
277        export_type: "address".to_string(),
278        record_count: transactions.len(),
279        output_path: args.output.display().to_string(),
280        format: format.to_string(),
281        exported_at: std::time::SystemTime::now()
282            .duration_since(std::time::UNIX_EPOCH)
283            .unwrap_or_default()
284            .as_secs(),
285    };
286
287    println!(
288        "Exported {} transactions to {}",
289        report.record_count, report.output_path
290    );
291
292    Ok(())
293}
294
295/// Parses a YYYY-MM-DD date string to a Unix timestamp.
296fn parse_date_to_ts(date: &str) -> Option<u64> {
297    let parts: Vec<&str> = date.split('-').collect();
298    if parts.len() != 3 {
299        return None;
300    }
301    let year: i32 = parts[0].parse().ok()?;
302    let month: u32 = parts[1].parse().ok()?;
303    let day: u32 = parts[2].parse().ok()?;
304
305    // Simple calculation: days since epoch * 86400
306    // Use chrono-like calculation without the crate
307    // For simplicity, use a basic approach
308    let days_from_epoch = days_since_epoch(year, month, day)?;
309    Some((days_from_epoch as u64) * 86400)
310}
311
312/// Calculates days since Unix epoch (1970-01-01) for a given date.
313fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
314    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
315        return None;
316    }
317
318    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
319    let y = if month <= 2 { year - 1 } else { year } as i64;
320    let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
321    let era = if y >= 0 { y } else { y - 399 } / 400;
322    let yoe = (y - era * 400) as u64;
323    let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
324    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
325    let days = era * 146097 + doe as i64 - 719468;
326    Some(days)
327}
328
329/// Exported transaction data.
330#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
331pub struct TransactionExport {
332    /// Transaction hash.
333    pub hash: String,
334
335    /// Block number.
336    pub block_number: u64,
337
338    /// Timestamp.
339    pub timestamp: u64,
340
341    /// Sender address.
342    pub from: String,
343
344    /// Recipient address.
345    pub to: Option<String>,
346
347    /// Value transferred.
348    pub value: String,
349
350    /// Gas used.
351    pub gas_used: u64,
352
353    /// Transaction status.
354    pub status: bool,
355}
356
357/// Export data container.
358#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
359pub struct ExportData {
360    /// The exported address.
361    pub address: String,
362
363    /// The chain.
364    pub chain: String,
365
366    /// Transactions.
367    pub transactions: Vec<TransactionExport>,
368
369    /// Export timestamp.
370    pub exported_at: u64,
371}
372
373// ============================================================================
374// Unit Tests
375// ============================================================================
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tempfile::TempDir;
381
382    #[test]
383    fn test_detect_format_json() {
384        let path = PathBuf::from("output.json");
385        assert_eq!(detect_format(&path), OutputFormat::Json);
386    }
387
388    #[test]
389    fn test_detect_format_csv() {
390        let path = PathBuf::from("output.csv");
391        assert_eq!(detect_format(&path), OutputFormat::Csv);
392    }
393
394    #[test]
395    fn test_detect_format_unknown_defaults_to_json() {
396        let path = PathBuf::from("output.txt");
397        assert_eq!(detect_format(&path), OutputFormat::Json);
398    }
399
400    #[test]
401    fn test_detect_format_no_extension() {
402        let path = PathBuf::from("output");
403        assert_eq!(detect_format(&path), OutputFormat::Json);
404    }
405
406    #[test]
407    fn test_export_args_parsing() {
408        use clap::Parser;
409
410        #[derive(Parser)]
411        struct TestCli {
412            #[command(flatten)]
413            args: ExportArgs,
414        }
415
416        let cli = TestCli::try_parse_from([
417            "test",
418            "--address",
419            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
420            "--output",
421            "output.json",
422        ])
423        .unwrap();
424
425        assert_eq!(
426            cli.args.address,
427            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
428        );
429        assert_eq!(cli.args.output, PathBuf::from("output.json"));
430        assert!(!cli.args.portfolio);
431    }
432
433    #[test]
434    fn test_export_args_portfolio_flag() {
435        use clap::Parser;
436
437        #[derive(Parser)]
438        struct TestCli {
439            #[command(flatten)]
440            args: ExportArgs,
441        }
442
443        let cli =
444            TestCli::try_parse_from(["test", "--portfolio", "--output", "portfolio.json"]).unwrap();
445
446        assert!(cli.args.portfolio);
447        assert!(cli.args.address.is_none());
448    }
449
450    #[test]
451    fn test_export_args_with_all_options() {
452        use clap::Parser;
453
454        #[derive(Parser)]
455        struct TestCli {
456            #[command(flatten)]
457            args: ExportArgs,
458        }
459
460        let cli = TestCli::try_parse_from([
461            "test",
462            "--address",
463            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
464            "--output",
465            "output.csv",
466            "--format",
467            "csv",
468            "--chain",
469            "polygon",
470            "--from",
471            "2024-01-01",
472            "--to",
473            "2024-12-31",
474            "--limit",
475            "500",
476        ])
477        .unwrap();
478
479        assert_eq!(cli.args.chain, "polygon");
480        assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
481        assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
482        assert_eq!(cli.args.limit, 500);
483        assert_eq!(cli.args.format, Some(OutputFormat::Csv));
484    }
485
486    #[test]
487    fn test_export_report_serialization() {
488        let report = ExportReport {
489            export_type: "address".to_string(),
490            record_count: 100,
491            output_path: "/tmp/output.json".to_string(),
492            format: "json".to_string(),
493            exported_at: 1700000000,
494        };
495
496        let json = serde_json::to_string(&report).unwrap();
497        assert!(json.contains("address"));
498        assert!(json.contains("100"));
499        assert!(json.contains("/tmp/output.json"));
500    }
501
502    #[test]
503    fn test_transaction_export_serialization() {
504        let tx = TransactionExport {
505            hash: "0xabc123".to_string(),
506            block_number: 12345,
507            timestamp: 1700000000,
508            from: "0xfrom".to_string(),
509            to: Some("0xto".to_string()),
510            value: "1.5".to_string(),
511            gas_used: 21000,
512            status: true,
513        };
514
515        let json = serde_json::to_string(&tx).unwrap();
516        assert!(json.contains("0xabc123"));
517        assert!(json.contains("12345"));
518        assert!(json.contains("21000"));
519    }
520
521    #[test]
522    fn test_export_data_serialization() {
523        let data = ExportData {
524            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
525            chain: "ethereum".to_string(),
526            transactions: vec![],
527            exported_at: 1700000000,
528        };
529
530        let json = serde_json::to_string(&data).unwrap();
531        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
532        assert!(json.contains("ethereum"));
533    }
534
535    #[tokio::test]
536    async fn test_export_portfolio_json() {
537        use crate::cli::portfolio::{Portfolio, WatchedAddress};
538
539        let temp_dir = TempDir::new().unwrap();
540        let data_dir = temp_dir.path().to_path_buf();
541        let output_path = temp_dir.path().join("portfolio.json");
542
543        // Create a test portfolio
544        let portfolio = Portfolio {
545            addresses: vec![WatchedAddress {
546                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
547                label: Some("Test".to_string()),
548                chain: "ethereum".to_string(),
549                tags: vec![],
550                added_at: 1700000000,
551            }],
552        };
553        portfolio.save(&data_dir).unwrap();
554
555        let config = Config {
556            portfolio: crate::config::PortfolioConfig {
557                data_dir: Some(data_dir),
558            },
559            ..Default::default()
560        };
561
562        let args = ExportArgs {
563            address: None,
564            portfolio: true,
565            output: output_path.clone(),
566            format: Some(OutputFormat::Json),
567            chain: "ethereum".to_string(),
568            from: None,
569            to: None,
570            limit: 1000,
571        };
572
573        let result = export_portfolio(&args, OutputFormat::Json, &config).await;
574        assert!(result.is_ok());
575        assert!(output_path.exists());
576
577        let content = std::fs::read_to_string(&output_path).unwrap();
578        assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
579    }
580
581    #[tokio::test]
582    async fn test_export_portfolio_csv() {
583        use crate::cli::portfolio::{Portfolio, WatchedAddress};
584
585        let temp_dir = TempDir::new().unwrap();
586        let data_dir = temp_dir.path().to_path_buf();
587        let output_path = temp_dir.path().join("portfolio.csv");
588
589        let portfolio = Portfolio {
590            addresses: vec![WatchedAddress {
591                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
592                label: Some("Test Wallet".to_string()),
593                chain: "ethereum".to_string(),
594                tags: vec!["personal".to_string()],
595                added_at: 1700000000,
596            }],
597        };
598        portfolio.save(&data_dir).unwrap();
599
600        let config = Config {
601            portfolio: crate::config::PortfolioConfig {
602                data_dir: Some(data_dir),
603            },
604            ..Default::default()
605        };
606
607        let args = ExportArgs {
608            address: None,
609            portfolio: true,
610            output: output_path.clone(),
611            format: Some(OutputFormat::Csv),
612            chain: "ethereum".to_string(),
613            from: None,
614            to: None,
615            limit: 1000,
616        };
617
618        let result = export_portfolio(&args, OutputFormat::Csv, &config).await;
619        assert!(result.is_ok());
620
621        let content = std::fs::read_to_string(&output_path).unwrap();
622        assert!(content.contains("address,label,chain,tags,added_at"));
623        assert!(content.contains("Test Wallet"));
624        assert!(content.contains("personal"));
625    }
626
627    // ========================================================================
628    // Date parsing and pure function tests
629    // ========================================================================
630
631    #[test]
632    fn test_parse_date_to_ts_valid() {
633        let ts = parse_date_to_ts("2024-01-01");
634        assert!(ts.is_some());
635        let ts = ts.unwrap();
636        // Jan 1, 2024 00:00:00 UTC should be around 1704067200
637        assert!(ts > 1700000000 && ts < 1710000000);
638    }
639
640    #[test]
641    fn test_parse_date_to_ts_epoch() {
642        let ts = parse_date_to_ts("1970-01-01");
643        assert_eq!(ts, Some(0));
644    }
645
646    #[test]
647    fn test_parse_date_to_ts_invalid_format() {
648        assert!(parse_date_to_ts("not-a-date").is_none());
649        assert!(parse_date_to_ts("2024/01/01").is_none());
650        assert!(parse_date_to_ts("2024-01").is_none());
651        assert!(parse_date_to_ts("").is_none());
652    }
653
654    #[test]
655    fn test_parse_date_to_ts_invalid_values() {
656        assert!(parse_date_to_ts("2024-13-01").is_none()); // Month > 12
657        assert!(parse_date_to_ts("2024-00-01").is_none()); // Month 0
658        assert!(parse_date_to_ts("2024-01-00").is_none()); // Day 0
659        assert!(parse_date_to_ts("2024-01-32").is_none()); // Day > 31
660    }
661
662    #[test]
663    fn test_days_since_epoch_basic() {
664        // Jan 1, 1970 should be day 0
665        let days = days_since_epoch(1970, 1, 1);
666        assert_eq!(days, Some(0));
667    }
668
669    #[test]
670    fn test_days_since_epoch_known_date() {
671        // 2000-01-01 is day 10957
672        let days = days_since_epoch(2000, 1, 1);
673        assert_eq!(days, Some(10957));
674    }
675
676    #[test]
677    fn test_days_since_epoch_invalid_month() {
678        assert!(days_since_epoch(2024, 13, 1).is_none());
679        assert!(days_since_epoch(2024, 0, 1).is_none());
680    }
681
682    #[test]
683    fn test_days_since_epoch_invalid_day() {
684        assert!(days_since_epoch(2024, 1, 0).is_none());
685        assert!(days_since_epoch(2024, 1, 32).is_none());
686    }
687
688    #[tokio::test]
689    async fn test_export_portfolio_table_error() {
690        let temp_dir = TempDir::new().unwrap();
691        let data_dir = temp_dir.path().to_path_buf();
692        let output_path = temp_dir.path().join("output.txt");
693
694        // Create empty portfolio
695        use crate::cli::portfolio::Portfolio;
696        let portfolio = Portfolio { addresses: vec![] };
697        portfolio.save(&data_dir).unwrap();
698
699        let config = Config {
700            portfolio: crate::config::PortfolioConfig {
701                data_dir: Some(data_dir),
702            },
703            ..Default::default()
704        };
705
706        let args = ExportArgs {
707            address: None,
708            portfolio: true,
709            output: output_path,
710            format: Some(OutputFormat::Table),
711            chain: "ethereum".to_string(),
712            from: None,
713            to: None,
714            limit: 1000,
715        };
716
717        let result = export_portfolio(&args, OutputFormat::Table, &config).await;
718        assert!(result.is_err()); // Table format not supported for export
719    }
720
721    #[tokio::test]
722    async fn test_run_no_source_error() {
723        let config = Config::default();
724        let args = ExportArgs {
725            address: None,
726            portfolio: false,
727            output: PathBuf::from("output.json"),
728            format: None,
729            chain: "ethereum".to_string(),
730            from: None,
731            to: None,
732            limit: 1000,
733        };
734
735        let factory = crate::chains::DefaultClientFactory {
736            chains_config: crate::config::ChainsConfig::default(),
737        };
738        let result = run(args, &config, &factory).await;
739        assert!(result.is_err());
740    }
741
742    // ========================================================================
743    // End-to-end tests using MockClientFactory
744    // ========================================================================
745
746    use crate::chains::mocks::{MockChainClient, MockClientFactory};
747
748    fn mock_factory() -> MockClientFactory {
749        let mut factory = MockClientFactory::new();
750        factory.mock_client = MockChainClient::new("ethereum", "ETH");
751        factory.mock_client.transactions = vec![crate::chains::Transaction {
752            hash: "0xexport1".to_string(),
753            block_number: Some(100),
754            timestamp: Some(1700000000),
755            from: "0xfrom".to_string(),
756            to: Some("0xto".to_string()),
757            value: "1.0".to_string(),
758            gas_limit: 21000,
759            gas_used: Some(21000),
760            gas_price: "20000000000".to_string(),
761            nonce: 0,
762            input: "0x".to_string(),
763            status: Some(true),
764        }];
765        factory
766    }
767
768    #[tokio::test]
769    async fn test_run_export_address_json() {
770        let config = Config::default();
771        let factory = mock_factory();
772        let tmp = tempfile::NamedTempFile::new().unwrap();
773        let args = ExportArgs {
774            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
775            portfolio: false,
776            output: tmp.path().to_path_buf(),
777            format: Some(OutputFormat::Json),
778            chain: "ethereum".to_string(),
779            from: None,
780            to: None,
781            limit: 100,
782        };
783        let result = run(args, &config, &factory).await;
784        assert!(result.is_ok());
785        // Verify file was written
786        let content = std::fs::read_to_string(tmp.path()).unwrap();
787        assert!(content.contains("0xexport1"));
788    }
789
790    #[tokio::test]
791    async fn test_run_export_address_csv() {
792        let config = Config::default();
793        let factory = mock_factory();
794        let tmp = tempfile::NamedTempFile::new().unwrap();
795        let args = ExportArgs {
796            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
797            portfolio: false,
798            output: tmp.path().to_path_buf(),
799            format: Some(OutputFormat::Csv),
800            chain: "ethereum".to_string(),
801            from: None,
802            to: None,
803            limit: 100,
804        };
805        let result = run(args, &config, &factory).await;
806        assert!(result.is_ok());
807        let content = std::fs::read_to_string(tmp.path()).unwrap();
808        assert!(content.contains("hash,block,timestamp"));
809    }
810
811    #[tokio::test]
812    async fn test_run_export_with_date_filter() {
813        let config = Config::default();
814        let factory = mock_factory();
815        let tmp = tempfile::NamedTempFile::new().unwrap();
816        let args = ExportArgs {
817            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
818            portfolio: false,
819            output: tmp.path().to_path_buf(),
820            format: Some(OutputFormat::Json),
821            chain: "ethereum".to_string(),
822            from: Some("2023-01-01".to_string()),
823            to: Some("2025-12-31".to_string()),
824            limit: 100,
825        };
826        let result = run(args, &config, &factory).await;
827        assert!(result.is_ok());
828    }
829}