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        OutputFormat::Markdown => {
158            let mut md = "# Portfolio Export\n\n".to_string();
159            md.push_str("| Address | Label | Chain | Tags | Added |\n|---------|-------|-------|------|-------|\n");
160            for addr in &portfolio.addresses {
161                md.push_str(&format!(
162                    "| `{}` | {} | {} | {} | {} |\n",
163                    addr.address,
164                    addr.label.as_deref().unwrap_or("-"),
165                    addr.chain,
166                    addr.tags.join(", "),
167                    addr.added_at
168                ));
169            }
170            md
171        }
172    };
173
174    std::fs::write(&args.output, &content)?;
175
176    let report = ExportReport {
177        export_type: "portfolio".to_string(),
178        record_count: portfolio.addresses.len(),
179        output_path: args.output.display().to_string(),
180        format: format.to_string(),
181        exported_at: std::time::SystemTime::now()
182            .duration_since(std::time::UNIX_EPOCH)
183            .unwrap_or_default()
184            .as_secs(),
185    };
186
187    println!(
188        "Exported {} portfolio addresses to {}",
189        report.record_count, report.output_path
190    );
191
192    Ok(())
193}
194
195/// Exports address transaction history.
196async fn export_address(
197    address: &str,
198    args: &ExportArgs,
199    format: OutputFormat,
200    clients: &dyn ChainClientFactory,
201) -> Result<()> {
202    // Auto-detect chain if default
203    let chain = if args.chain == "ethereum" {
204        infer_chain_from_address(address)
205            .unwrap_or("ethereum")
206            .to_string()
207    } else {
208        args.chain.clone()
209    };
210
211    tracing::info!(
212        address = %address,
213        chain = %chain,
214        "Exporting address data"
215    );
216
217    println!("Fetching transactions for {} on {}...", address, chain);
218
219    // Fetch real transaction history
220    let client = clients.create_chain_client(&chain)?;
221    let chain_txs = client.get_transactions(address, args.limit).await?;
222
223    // Apply date filtering if --from / --to are provided
224    let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
225    let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
226
227    let transactions: Vec<TransactionExport> = chain_txs
228        .into_iter()
229        .filter(|tx| {
230            let ts = tx.timestamp.unwrap_or(0);
231            if let Some(from) = from_ts
232                && ts < from
233            {
234                return false;
235            }
236            if let Some(to) = to_ts
237                && ts > to
238            {
239                return false;
240            }
241            true
242        })
243        .map(|tx| TransactionExport {
244            hash: tx.hash,
245            block_number: tx.block_number.unwrap_or(0),
246            timestamp: tx.timestamp.unwrap_or(0),
247            from: tx.from,
248            to: tx.to,
249            value: tx.value,
250            gas_used: tx.gas_used.unwrap_or(0),
251            status: tx.status.unwrap_or(true),
252        })
253        .collect();
254
255    let content = match format {
256        OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
257            address: address.to_string(),
258            chain: chain.clone(),
259            transactions: transactions.clone(),
260            exported_at: std::time::SystemTime::now()
261                .duration_since(std::time::UNIX_EPOCH)
262                .unwrap_or_default()
263                .as_secs(),
264        })?,
265        OutputFormat::Csv => {
266            let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
267            for tx in &transactions {
268                csv.push_str(&format!(
269                    "{},{},{},{},{},{},{},{}\n",
270                    tx.hash,
271                    tx.block_number,
272                    tx.timestamp,
273                    tx.from,
274                    tx.to.as_deref().unwrap_or(""),
275                    tx.value,
276                    tx.gas_used,
277                    tx.status
278                ));
279            }
280            csv
281        }
282        OutputFormat::Table => {
283            return Err(ScopeError::Export(
284                "Table format not supported for file export".to_string(),
285            ));
286        }
287        OutputFormat::Markdown => {
288            let mut md = format!(
289                "# Transaction Export\n\n**Address:** `{}`  \n**Chain:** {}  \n**Transactions:** {}  \n\n",
290                address,
291                chain,
292                transactions.len()
293            );
294            md.push_str("| Hash | Block | Timestamp | From | To | Value | Gas | Status |\n");
295            md.push_str("|------|-------|-----------|------|----|-------|-----|--------|\n");
296            for tx in &transactions {
297                md.push_str(&format!(
298                    "| `{}` | {} | {} | `{}` | `{}` | {} | {} | {} |\n",
299                    tx.hash,
300                    tx.block_number,
301                    tx.timestamp,
302                    tx.from,
303                    tx.to.as_deref().unwrap_or("-"),
304                    tx.value,
305                    tx.gas_used,
306                    tx.status
307                ));
308            }
309            md
310        }
311    };
312
313    std::fs::write(&args.output, &content)?;
314
315    let report = ExportReport {
316        export_type: "address".to_string(),
317        record_count: transactions.len(),
318        output_path: args.output.display().to_string(),
319        format: format.to_string(),
320        exported_at: std::time::SystemTime::now()
321            .duration_since(std::time::UNIX_EPOCH)
322            .unwrap_or_default()
323            .as_secs(),
324    };
325
326    println!(
327        "Exported {} transactions to {}",
328        report.record_count, report.output_path
329    );
330
331    Ok(())
332}
333
334/// Parses a YYYY-MM-DD date string to a Unix timestamp.
335fn parse_date_to_ts(date: &str) -> Option<u64> {
336    let parts: Vec<&str> = date.split('-').collect();
337    if parts.len() != 3 {
338        return None;
339    }
340    let year: i32 = parts[0].parse().ok()?;
341    let month: u32 = parts[1].parse().ok()?;
342    let day: u32 = parts[2].parse().ok()?;
343
344    // Simple calculation: days since epoch * 86400
345    // Use chrono-like calculation without the crate
346    // For simplicity, use a basic approach
347    let days_from_epoch = days_since_epoch(year, month, day)?;
348    Some((days_from_epoch as u64) * 86400)
349}
350
351/// Calculates days since Unix epoch (1970-01-01) for a given date.
352fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
353    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
354        return None;
355    }
356
357    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
358    let y = if month <= 2 { year - 1 } else { year } as i64;
359    let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
360    let era = if y >= 0 { y } else { y - 399 } / 400;
361    let yoe = (y - era * 400) as u64;
362    let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
363    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
364    let days = era * 146097 + doe as i64 - 719468;
365    Some(days)
366}
367
368/// Exported transaction data.
369#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
370pub struct TransactionExport {
371    /// Transaction hash.
372    pub hash: String,
373
374    /// Block number.
375    pub block_number: u64,
376
377    /// Timestamp.
378    pub timestamp: u64,
379
380    /// Sender address.
381    pub from: String,
382
383    /// Recipient address.
384    pub to: Option<String>,
385
386    /// Value transferred.
387    pub value: String,
388
389    /// Gas used.
390    pub gas_used: u64,
391
392    /// Transaction status.
393    pub status: bool,
394}
395
396/// Export data container.
397#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
398pub struct ExportData {
399    /// The exported address.
400    pub address: String,
401
402    /// The chain.
403    pub chain: String,
404
405    /// Transactions.
406    pub transactions: Vec<TransactionExport>,
407
408    /// Export timestamp.
409    pub exported_at: u64,
410}
411
412// ============================================================================
413// Unit Tests
414// ============================================================================
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use tempfile::TempDir;
420
421    #[test]
422    fn test_detect_format_json() {
423        let path = PathBuf::from("output.json");
424        assert_eq!(detect_format(&path), OutputFormat::Json);
425    }
426
427    #[test]
428    fn test_detect_format_csv() {
429        let path = PathBuf::from("output.csv");
430        assert_eq!(detect_format(&path), OutputFormat::Csv);
431    }
432
433    #[test]
434    fn test_detect_format_unknown_defaults_to_json() {
435        let path = PathBuf::from("output.txt");
436        assert_eq!(detect_format(&path), OutputFormat::Json);
437    }
438
439    #[test]
440    fn test_detect_format_no_extension() {
441        let path = PathBuf::from("output");
442        assert_eq!(detect_format(&path), OutputFormat::Json);
443    }
444
445    #[test]
446    fn test_export_args_parsing() {
447        use clap::Parser;
448
449        #[derive(Parser)]
450        struct TestCli {
451            #[command(flatten)]
452            args: ExportArgs,
453        }
454
455        let cli = TestCli::try_parse_from([
456            "test",
457            "--address",
458            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
459            "--output",
460            "output.json",
461        ])
462        .unwrap();
463
464        assert_eq!(
465            cli.args.address,
466            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
467        );
468        assert_eq!(cli.args.output, PathBuf::from("output.json"));
469        assert!(!cli.args.portfolio);
470    }
471
472    #[test]
473    fn test_export_args_portfolio_flag() {
474        use clap::Parser;
475
476        #[derive(Parser)]
477        struct TestCli {
478            #[command(flatten)]
479            args: ExportArgs,
480        }
481
482        let cli =
483            TestCli::try_parse_from(["test", "--portfolio", "--output", "portfolio.json"]).unwrap();
484
485        assert!(cli.args.portfolio);
486        assert!(cli.args.address.is_none());
487    }
488
489    #[test]
490    fn test_export_args_with_all_options() {
491        use clap::Parser;
492
493        #[derive(Parser)]
494        struct TestCli {
495            #[command(flatten)]
496            args: ExportArgs,
497        }
498
499        let cli = TestCli::try_parse_from([
500            "test",
501            "--address",
502            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
503            "--output",
504            "output.csv",
505            "--format",
506            "csv",
507            "--chain",
508            "polygon",
509            "--from",
510            "2024-01-01",
511            "--to",
512            "2024-12-31",
513            "--limit",
514            "500",
515        ])
516        .unwrap();
517
518        assert_eq!(cli.args.chain, "polygon");
519        assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
520        assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
521        assert_eq!(cli.args.limit, 500);
522        assert_eq!(cli.args.format, Some(OutputFormat::Csv));
523    }
524
525    #[test]
526    fn test_export_report_serialization() {
527        let report = ExportReport {
528            export_type: "address".to_string(),
529            record_count: 100,
530            output_path: "/tmp/output.json".to_string(),
531            format: "json".to_string(),
532            exported_at: 1700000000,
533        };
534
535        let json = serde_json::to_string(&report).unwrap();
536        assert!(json.contains("address"));
537        assert!(json.contains("100"));
538        assert!(json.contains("/tmp/output.json"));
539    }
540
541    #[test]
542    fn test_transaction_export_serialization() {
543        let tx = TransactionExport {
544            hash: "0xabc123".to_string(),
545            block_number: 12345,
546            timestamp: 1700000000,
547            from: "0xfrom".to_string(),
548            to: Some("0xto".to_string()),
549            value: "1.5".to_string(),
550            gas_used: 21000,
551            status: true,
552        };
553
554        let json = serde_json::to_string(&tx).unwrap();
555        assert!(json.contains("0xabc123"));
556        assert!(json.contains("12345"));
557        assert!(json.contains("21000"));
558    }
559
560    #[test]
561    fn test_export_data_serialization() {
562        let data = ExportData {
563            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
564            chain: "ethereum".to_string(),
565            transactions: vec![],
566            exported_at: 1700000000,
567        };
568
569        let json = serde_json::to_string(&data).unwrap();
570        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
571        assert!(json.contains("ethereum"));
572    }
573
574    #[tokio::test]
575    async fn test_export_portfolio_json() {
576        use crate::cli::portfolio::{Portfolio, WatchedAddress};
577
578        let temp_dir = TempDir::new().unwrap();
579        let data_dir = temp_dir.path().to_path_buf();
580        let output_path = temp_dir.path().join("portfolio.json");
581
582        // Create a test portfolio
583        let portfolio = Portfolio {
584            addresses: vec![WatchedAddress {
585                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
586                label: Some("Test".to_string()),
587                chain: "ethereum".to_string(),
588                tags: vec![],
589                added_at: 1700000000,
590            }],
591        };
592        portfolio.save(&data_dir).unwrap();
593
594        let config = Config {
595            portfolio: crate::config::PortfolioConfig {
596                data_dir: Some(data_dir),
597            },
598            ..Default::default()
599        };
600
601        let args = ExportArgs {
602            address: None,
603            portfolio: true,
604            output: output_path.clone(),
605            format: Some(OutputFormat::Json),
606            chain: "ethereum".to_string(),
607            from: None,
608            to: None,
609            limit: 1000,
610        };
611
612        let result = export_portfolio(&args, OutputFormat::Json, &config).await;
613        assert!(result.is_ok());
614        assert!(output_path.exists());
615
616        let content = std::fs::read_to_string(&output_path).unwrap();
617        assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
618    }
619
620    #[tokio::test]
621    async fn test_export_portfolio_csv() {
622        use crate::cli::portfolio::{Portfolio, WatchedAddress};
623
624        let temp_dir = TempDir::new().unwrap();
625        let data_dir = temp_dir.path().to_path_buf();
626        let output_path = temp_dir.path().join("portfolio.csv");
627
628        let portfolio = Portfolio {
629            addresses: vec![WatchedAddress {
630                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
631                label: Some("Test Wallet".to_string()),
632                chain: "ethereum".to_string(),
633                tags: vec!["personal".to_string()],
634                added_at: 1700000000,
635            }],
636        };
637        portfolio.save(&data_dir).unwrap();
638
639        let config = Config {
640            portfolio: crate::config::PortfolioConfig {
641                data_dir: Some(data_dir),
642            },
643            ..Default::default()
644        };
645
646        let args = ExportArgs {
647            address: None,
648            portfolio: true,
649            output: output_path.clone(),
650            format: Some(OutputFormat::Csv),
651            chain: "ethereum".to_string(),
652            from: None,
653            to: None,
654            limit: 1000,
655        };
656
657        let result = export_portfolio(&args, OutputFormat::Csv, &config).await;
658        assert!(result.is_ok());
659
660        let content = std::fs::read_to_string(&output_path).unwrap();
661        assert!(content.contains("address,label,chain,tags,added_at"));
662        assert!(content.contains("Test Wallet"));
663        assert!(content.contains("personal"));
664    }
665
666    // ========================================================================
667    // Date parsing and pure function tests
668    // ========================================================================
669
670    #[test]
671    fn test_parse_date_to_ts_valid() {
672        let ts = parse_date_to_ts("2024-01-01");
673        assert!(ts.is_some());
674        let ts = ts.unwrap();
675        // Jan 1, 2024 00:00:00 UTC should be around 1704067200
676        assert!(ts > 1700000000 && ts < 1710000000);
677    }
678
679    #[test]
680    fn test_parse_date_to_ts_epoch() {
681        let ts = parse_date_to_ts("1970-01-01");
682        assert_eq!(ts, Some(0));
683    }
684
685    #[test]
686    fn test_parse_date_to_ts_invalid_format() {
687        assert!(parse_date_to_ts("not-a-date").is_none());
688        assert!(parse_date_to_ts("2024/01/01").is_none());
689        assert!(parse_date_to_ts("2024-01").is_none());
690        assert!(parse_date_to_ts("").is_none());
691    }
692
693    #[test]
694    fn test_parse_date_to_ts_invalid_values() {
695        assert!(parse_date_to_ts("2024-13-01").is_none()); // Month > 12
696        assert!(parse_date_to_ts("2024-00-01").is_none()); // Month 0
697        assert!(parse_date_to_ts("2024-01-00").is_none()); // Day 0
698        assert!(parse_date_to_ts("2024-01-32").is_none()); // Day > 31
699    }
700
701    #[test]
702    fn test_days_since_epoch_basic() {
703        // Jan 1, 1970 should be day 0
704        let days = days_since_epoch(1970, 1, 1);
705        assert_eq!(days, Some(0));
706    }
707
708    #[test]
709    fn test_days_since_epoch_known_date() {
710        // 2000-01-01 is day 10957
711        let days = days_since_epoch(2000, 1, 1);
712        assert_eq!(days, Some(10957));
713    }
714
715    #[test]
716    fn test_days_since_epoch_invalid_month() {
717        assert!(days_since_epoch(2024, 13, 1).is_none());
718        assert!(days_since_epoch(2024, 0, 1).is_none());
719    }
720
721    #[test]
722    fn test_days_since_epoch_invalid_day() {
723        assert!(days_since_epoch(2024, 1, 0).is_none());
724        assert!(days_since_epoch(2024, 1, 32).is_none());
725    }
726
727    #[tokio::test]
728    async fn test_export_portfolio_table_error() {
729        let temp_dir = TempDir::new().unwrap();
730        let data_dir = temp_dir.path().to_path_buf();
731        let output_path = temp_dir.path().join("output.txt");
732
733        // Create empty portfolio
734        use crate::cli::portfolio::Portfolio;
735        let portfolio = Portfolio { addresses: vec![] };
736        portfolio.save(&data_dir).unwrap();
737
738        let config = Config {
739            portfolio: crate::config::PortfolioConfig {
740                data_dir: Some(data_dir),
741            },
742            ..Default::default()
743        };
744
745        let args = ExportArgs {
746            address: None,
747            portfolio: true,
748            output: output_path,
749            format: Some(OutputFormat::Table),
750            chain: "ethereum".to_string(),
751            from: None,
752            to: None,
753            limit: 1000,
754        };
755
756        let result = export_portfolio(&args, OutputFormat::Table, &config).await;
757        assert!(result.is_err()); // Table format not supported for export
758    }
759
760    #[tokio::test]
761    async fn test_run_no_source_error() {
762        let config = Config::default();
763        let args = ExportArgs {
764            address: None,
765            portfolio: false,
766            output: PathBuf::from("output.json"),
767            format: None,
768            chain: "ethereum".to_string(),
769            from: None,
770            to: None,
771            limit: 1000,
772        };
773
774        let factory = crate::chains::DefaultClientFactory {
775            chains_config: crate::config::ChainsConfig::default(),
776        };
777        let result = run(args, &config, &factory).await;
778        assert!(result.is_err());
779    }
780
781    // ========================================================================
782    // End-to-end tests using MockClientFactory
783    // ========================================================================
784
785    use crate::chains::mocks::{MockChainClient, MockClientFactory};
786
787    fn mock_factory() -> MockClientFactory {
788        let mut factory = MockClientFactory::new();
789        factory.mock_client = MockChainClient::new("ethereum", "ETH");
790        factory.mock_client.transactions = vec![crate::chains::Transaction {
791            hash: "0xexport1".to_string(),
792            block_number: Some(100),
793            timestamp: Some(1700000000),
794            from: "0xfrom".to_string(),
795            to: Some("0xto".to_string()),
796            value: "1.0".to_string(),
797            gas_limit: 21000,
798            gas_used: Some(21000),
799            gas_price: "20000000000".to_string(),
800            nonce: 0,
801            input: "0x".to_string(),
802            status: Some(true),
803        }];
804        factory
805    }
806
807    #[tokio::test]
808    async fn test_run_export_address_json() {
809        let config = Config::default();
810        let factory = mock_factory();
811        let tmp = tempfile::NamedTempFile::new().unwrap();
812        let args = ExportArgs {
813            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
814            portfolio: false,
815            output: tmp.path().to_path_buf(),
816            format: Some(OutputFormat::Json),
817            chain: "ethereum".to_string(),
818            from: None,
819            to: None,
820            limit: 100,
821        };
822        let result = run(args, &config, &factory).await;
823        assert!(result.is_ok());
824        // Verify file was written
825        let content = std::fs::read_to_string(tmp.path()).unwrap();
826        assert!(content.contains("0xexport1"));
827    }
828
829    #[tokio::test]
830    async fn test_run_export_address_csv() {
831        let config = Config::default();
832        let factory = mock_factory();
833        let tmp = tempfile::NamedTempFile::new().unwrap();
834        let args = ExportArgs {
835            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
836            portfolio: false,
837            output: tmp.path().to_path_buf(),
838            format: Some(OutputFormat::Csv),
839            chain: "ethereum".to_string(),
840            from: None,
841            to: None,
842            limit: 100,
843        };
844        let result = run(args, &config, &factory).await;
845        assert!(result.is_ok());
846        let content = std::fs::read_to_string(tmp.path()).unwrap();
847        assert!(content.contains("hash,block,timestamp"));
848    }
849
850    #[tokio::test]
851    async fn test_run_export_with_date_filter() {
852        let config = Config::default();
853        let factory = mock_factory();
854        let tmp = tempfile::NamedTempFile::new().unwrap();
855        let args = ExportArgs {
856            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
857            portfolio: false,
858            output: tmp.path().to_path_buf(),
859            format: Some(OutputFormat::Json),
860            chain: "ethereum".to_string(),
861            from: Some("2023-01-01".to_string()),
862            to: Some("2025-12-31".to_string()),
863            limit: 100,
864        };
865        let result = run(args, &config, &factory).await;
866        assert!(result.is_ok());
867    }
868}