Skip to main content

scope/cli/
export.rs

1//! # Export Command
2//!
3//! This module implements the `scope 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//! scope export --address 0x742d... --output history.json
11//!
12//! # Export to CSV
13//! scope export --address 0x742d... --output history.csv --format csv
14//!
15//! # Export address book data
16//! scope export --address-book --output address_book.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)]
27#[command(after_help = "\x1b[1mExamples:\x1b[0m
28  scope export --address 0x742d... --output txns.csv
29  scope export --address @main-wallet --output txns.json  \x1b[2m# address book shortcut\x1b[0m
30  scope export --address 0x742d... --output txns.json --from 2025-01-01 --to 2025-12-31
31  scope export --address-book --output portfolio.csv
32  scope export -a 0x742d... -o data.csv --chain polygon --limit 500")]
33pub struct ExportArgs {
34    /// Address to export data for. Use @label for address book shortcut.
35    #[arg(short, long, value_name = "ADDRESS", group = "source")]
36    pub address: Option<String>,
37
38    /// Export address book data.
39    #[arg(
40        long = "address-book",
41        short = 'p',
42        alias = "portfolio",
43        group = "source"
44    )]
45    pub address_book: bool,
46
47    /// Output file path.
48    #[arg(short, long, value_name = "PATH")]
49    pub output: PathBuf,
50
51    /// Output format (auto-detected from extension if not specified).
52    #[arg(short, long, value_name = "FORMAT")]
53    pub format: Option<OutputFormat>,
54
55    /// Target blockchain network (for address export).
56    #[arg(short, long, default_value = "ethereum")]
57    pub chain: String,
58
59    /// Start date for transaction history (YYYY-MM-DD).
60    #[arg(long, value_name = "DATE")]
61    pub from: Option<String>,
62
63    /// End date for transaction history (YYYY-MM-DD).
64    #[arg(long, value_name = "DATE")]
65    pub to: Option<String>,
66
67    /// Maximum number of transactions to export.
68    #[arg(long, default_value = "1000")]
69    pub limit: u32,
70}
71
72/// Data export report.
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct ExportReport {
75    /// Export type (address, address book).
76    pub export_type: String,
77
78    /// Number of records exported.
79    pub record_count: usize,
80
81    /// Output file path.
82    pub output_path: String,
83
84    /// Export format.
85    pub format: String,
86
87    /// Export timestamp.
88    pub exported_at: u64,
89}
90
91/// Executes the export command.
92///
93/// # Arguments
94///
95/// * `args` - The parsed command arguments
96/// * `config` - Application configuration
97///
98/// # Returns
99///
100/// Returns `Ok(())` on success, or an error if the export fails.
101///
102/// # Errors
103///
104/// Returns [`ScopeError::Export`] if the export operation fails.
105/// Returns [`ScopeError::Io`] if file operations fail.
106pub async fn run(
107    mut args: ExportArgs,
108    config: &Config,
109    clients: &dyn ChainClientFactory,
110) -> Result<()> {
111    // Resolve address book label → address + chain
112    if let Some(ref input) = args.address
113        && let Some((address, chain)) =
114            crate::cli::address_book::resolve_address_book_input(input, config)?
115    {
116        args.address = Some(address);
117        if args.chain == "ethereum" {
118            args.chain = chain;
119        }
120    }
121
122    // Determine format from argument or file extension
123    let format = args.format.unwrap_or_else(|| detect_format(&args.output));
124
125    tracing::info!(
126        output = %args.output.display(),
127        format = %format,
128        "Starting export"
129    );
130
131    let sp = crate::cli::progress::Spinner::new("Exporting data...");
132    let result = if args.address_book {
133        export_address_book(&args, format, config).await
134    } else if let Some(ref address) = args.address {
135        export_address(address, &args, format, clients).await
136    } else {
137        Err(ScopeError::Export(
138            "Must specify either --address or --address-book".to_string(),
139        ))
140    };
141    sp.finish_and_clear();
142    result
143}
144
145/// Detects output format from file extension.
146fn detect_format(path: &std::path::Path) -> OutputFormat {
147    match path.extension().and_then(|e| e.to_str()) {
148        Some("json") => OutputFormat::Json,
149        Some("csv") => OutputFormat::Csv,
150        _ => OutputFormat::Json, // Default to JSON
151    }
152}
153
154/// Exports address book data.
155async fn export_address_book(
156    args: &ExportArgs,
157    format: OutputFormat,
158    config: &Config,
159) -> Result<()> {
160    use crate::cli::address_book::AddressBook;
161
162    let data_dir = config.data_dir();
163    let address_book = AddressBook::load(&data_dir)?;
164
165    let content = match format {
166        OutputFormat::Json => serde_json::to_string_pretty(&address_book)?,
167        OutputFormat::Csv => {
168            let mut csv = String::from("address,label,chain,tags,added_at\n");
169            for addr in &address_book.addresses {
170                csv.push_str(&format!(
171                    "{},{},{},{},{}\n",
172                    addr.address,
173                    addr.label.as_deref().unwrap_or(""),
174                    addr.chain,
175                    addr.tags.join(";"),
176                    addr.added_at
177                ));
178            }
179            csv
180        }
181        OutputFormat::Table => {
182            return Err(ScopeError::Export(
183                "Table format not supported for file export".to_string(),
184            ));
185        }
186        OutputFormat::Markdown => {
187            let mut md = "# Address Book Export\n\n".to_string();
188            md.push_str("| Address | Label | Chain | Tags | Added |\n|---------|-------|-------|------|-------|\n");
189            for addr in &address_book.addresses {
190                md.push_str(&format!(
191                    "| `{}` | {} | {} | {} | {} |\n",
192                    addr.address,
193                    addr.label.as_deref().unwrap_or("-"),
194                    addr.chain,
195                    addr.tags.join(", "),
196                    addr.added_at
197                ));
198            }
199            md
200        }
201    };
202
203    std::fs::write(&args.output, &content)?;
204
205    let report = ExportReport {
206        export_type: "address book".to_string(),
207        record_count: address_book.addresses.len(),
208        output_path: args.output.display().to_string(),
209        format: format.to_string(),
210        exported_at: std::time::SystemTime::now()
211            .duration_since(std::time::UNIX_EPOCH)
212            .unwrap_or_default()
213            .as_secs(),
214    };
215
216    println!(
217        "Exported {} address book addresses to {}",
218        report.record_count, report.output_path
219    );
220
221    Ok(())
222}
223
224/// Exports address transaction history.
225async fn export_address(
226    address: &str,
227    args: &ExportArgs,
228    format: OutputFormat,
229    clients: &dyn ChainClientFactory,
230) -> Result<()> {
231    // Auto-detect chain if default
232    let chain = if args.chain == "ethereum" {
233        infer_chain_from_address(address)
234            .unwrap_or("ethereum")
235            .to_string()
236    } else {
237        args.chain.clone()
238    };
239
240    tracing::info!(
241        address = %address,
242        chain = %chain,
243        "Exporting address data"
244    );
245
246    eprintln!("  Fetching transactions for {} on {}...", address, chain);
247
248    // Fetch real transaction history
249    let client = clients.create_chain_client(&chain)?;
250    let chain_txs = client.get_transactions(address, args.limit).await?;
251
252    // Apply date filtering if --from / --to are provided
253    let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
254    let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
255
256    let transactions: Vec<TransactionExport> = chain_txs
257        .into_iter()
258        .filter(|tx| {
259            let ts = tx.timestamp.unwrap_or(0);
260            if let Some(from) = from_ts
261                && ts < from
262            {
263                return false;
264            }
265            if let Some(to) = to_ts
266                && ts > to
267            {
268                return false;
269            }
270            true
271        })
272        .map(|tx| TransactionExport {
273            hash: tx.hash,
274            block_number: tx.block_number.unwrap_or(0),
275            timestamp: tx.timestamp.unwrap_or(0),
276            from: tx.from,
277            to: tx.to,
278            value: tx.value,
279            gas_used: tx.gas_used.unwrap_or(0),
280            status: tx.status.unwrap_or(true),
281        })
282        .collect();
283
284    let content = match format {
285        OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
286            address: address.to_string(),
287            chain: chain.clone(),
288            transactions: transactions.clone(),
289            exported_at: std::time::SystemTime::now()
290                .duration_since(std::time::UNIX_EPOCH)
291                .unwrap_or_default()
292                .as_secs(),
293        })?,
294        OutputFormat::Csv => {
295            let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
296            for tx in &transactions {
297                csv.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            csv
310        }
311        OutputFormat::Table => {
312            return Err(ScopeError::Export(
313                "Table format not supported for file export".to_string(),
314            ));
315        }
316        OutputFormat::Markdown => {
317            let mut md = format!(
318                "# Transaction Export\n\n**Address:** `{}`  \n**Chain:** {}  \n**Transactions:** {}  \n\n",
319                address,
320                chain,
321                transactions.len()
322            );
323            md.push_str("| Hash | Block | Timestamp | From | To | Value | Gas | Status |\n");
324            md.push_str("|------|-------|-----------|------|----|-------|-----|--------|\n");
325            for tx in &transactions {
326                md.push_str(&format!(
327                    "| `{}` | {} | {} | `{}` | `{}` | {} | {} | {} |\n",
328                    tx.hash,
329                    tx.block_number,
330                    tx.timestamp,
331                    tx.from,
332                    tx.to.as_deref().unwrap_or("-"),
333                    tx.value,
334                    tx.gas_used,
335                    tx.status
336                ));
337            }
338            md
339        }
340    };
341
342    std::fs::write(&args.output, &content)?;
343
344    let report = ExportReport {
345        export_type: "address".to_string(),
346        record_count: transactions.len(),
347        output_path: args.output.display().to_string(),
348        format: format.to_string(),
349        exported_at: std::time::SystemTime::now()
350            .duration_since(std::time::UNIX_EPOCH)
351            .unwrap_or_default()
352            .as_secs(),
353    };
354
355    println!(
356        "Exported {} transactions to {}",
357        report.record_count, report.output_path
358    );
359
360    Ok(())
361}
362
363/// Parses a YYYY-MM-DD date string to a Unix timestamp.
364fn parse_date_to_ts(date: &str) -> Option<u64> {
365    let parts: Vec<&str> = date.split('-').collect();
366    if parts.len() != 3 {
367        return None;
368    }
369    let year: i32 = parts[0].parse().ok()?;
370    let month: u32 = parts[1].parse().ok()?;
371    let day: u32 = parts[2].parse().ok()?;
372
373    // Simple calculation: days since epoch * 86400
374    // Use chrono-like calculation without the crate
375    // For simplicity, use a basic approach
376    let days_from_epoch = days_since_epoch(year, month, day)?;
377    Some((days_from_epoch as u64) * 86400)
378}
379
380/// Calculates days since Unix epoch (1970-01-01) for a given date.
381fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
382    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
383        return None;
384    }
385
386    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
387    let y = if month <= 2 { year - 1 } else { year } as i64;
388    let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
389    let era = if y >= 0 { y } else { y - 399 } / 400;
390    let yoe = (y - era * 400) as u64;
391    let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
392    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
393    let days = era * 146097 + doe as i64 - 719468;
394    Some(days)
395}
396
397/// Exported transaction data.
398#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
399pub struct TransactionExport {
400    /// Transaction hash.
401    pub hash: String,
402
403    /// Block number.
404    pub block_number: u64,
405
406    /// Timestamp.
407    pub timestamp: u64,
408
409    /// Sender address.
410    pub from: String,
411
412    /// Recipient address.
413    pub to: Option<String>,
414
415    /// Value transferred.
416    pub value: String,
417
418    /// Gas used.
419    pub gas_used: u64,
420
421    /// Transaction status.
422    pub status: bool,
423}
424
425/// Export data container.
426#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
427pub struct ExportData {
428    /// The exported address.
429    pub address: String,
430
431    /// The chain.
432    pub chain: String,
433
434    /// Transactions.
435    pub transactions: Vec<TransactionExport>,
436
437    /// Export timestamp.
438    pub exported_at: u64,
439}
440
441// ============================================================================
442// Unit Tests
443// ============================================================================
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use tempfile::TempDir;
449
450    #[test]
451    fn test_detect_format_json() {
452        let path = PathBuf::from("output.json");
453        assert_eq!(detect_format(&path), OutputFormat::Json);
454    }
455
456    #[test]
457    fn test_detect_format_csv() {
458        let path = PathBuf::from("output.csv");
459        assert_eq!(detect_format(&path), OutputFormat::Csv);
460    }
461
462    #[test]
463    fn test_detect_format_unknown_defaults_to_json() {
464        let path = PathBuf::from("output.txt");
465        assert_eq!(detect_format(&path), OutputFormat::Json);
466    }
467
468    #[test]
469    fn test_detect_format_no_extension() {
470        let path = PathBuf::from("output");
471        assert_eq!(detect_format(&path), OutputFormat::Json);
472    }
473
474    #[test]
475    fn test_export_args_parsing() {
476        use clap::Parser;
477
478        #[derive(Parser)]
479        struct TestCli {
480            #[command(flatten)]
481            args: ExportArgs,
482        }
483
484        let cli = TestCli::try_parse_from([
485            "test",
486            "--address",
487            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
488            "--output",
489            "output.json",
490        ])
491        .unwrap();
492
493        assert_eq!(
494            cli.args.address,
495            Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
496        );
497        assert_eq!(cli.args.output, PathBuf::from("output.json"));
498        assert!(!cli.args.address_book);
499    }
500
501    #[test]
502    fn test_export_args_address_book_flag() {
503        use clap::Parser;
504
505        #[derive(Parser)]
506        struct TestCli {
507            #[command(flatten)]
508            args: ExportArgs,
509        }
510
511        // Test primary --address-book flag
512        let cli =
513            TestCli::try_parse_from(["test", "--address-book", "--output", "address_book.json"])
514                .unwrap();
515        assert!(cli.args.address_book);
516        assert!(cli.args.address.is_none());
517
518        // Test backward-compat --portfolio alias
519        let cli = TestCli::try_parse_from(["test", "--portfolio", "--output", "address_book.json"])
520            .unwrap();
521        assert!(cli.args.address_book);
522    }
523
524    #[test]
525    fn test_export_args_with_all_options() {
526        use clap::Parser;
527
528        #[derive(Parser)]
529        struct TestCli {
530            #[command(flatten)]
531            args: ExportArgs,
532        }
533
534        let cli = TestCli::try_parse_from([
535            "test",
536            "--address",
537            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
538            "--output",
539            "output.csv",
540            "--format",
541            "csv",
542            "--chain",
543            "polygon",
544            "--from",
545            "2024-01-01",
546            "--to",
547            "2024-12-31",
548            "--limit",
549            "500",
550        ])
551        .unwrap();
552
553        assert_eq!(cli.args.chain, "polygon");
554        assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
555        assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
556        assert_eq!(cli.args.limit, 500);
557        assert_eq!(cli.args.format, Some(OutputFormat::Csv));
558    }
559
560    #[test]
561    fn test_export_report_serialization() {
562        let report = ExportReport {
563            export_type: "address".to_string(),
564            record_count: 100,
565            output_path: "/tmp/output.json".to_string(),
566            format: "json".to_string(),
567            exported_at: 1700000000,
568        };
569
570        let json = serde_json::to_string(&report).unwrap();
571        assert!(json.contains("address"));
572        assert!(json.contains("100"));
573        assert!(json.contains("/tmp/output.json"));
574    }
575
576    #[test]
577    fn test_transaction_export_serialization() {
578        let tx = TransactionExport {
579            hash: "0xabc123".to_string(),
580            block_number: 12345,
581            timestamp: 1700000000,
582            from: "0xfrom".to_string(),
583            to: Some("0xto".to_string()),
584            value: "1.5".to_string(),
585            gas_used: 21000,
586            status: true,
587        };
588
589        let json = serde_json::to_string(&tx).unwrap();
590        assert!(json.contains("0xabc123"));
591        assert!(json.contains("12345"));
592        assert!(json.contains("21000"));
593    }
594
595    #[test]
596    fn test_export_data_serialization() {
597        let data = ExportData {
598            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
599            chain: "ethereum".to_string(),
600            transactions: vec![],
601            exported_at: 1700000000,
602        };
603
604        let json = serde_json::to_string(&data).unwrap();
605        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
606        assert!(json.contains("ethereum"));
607    }
608
609    #[tokio::test]
610    async fn test_export_address_book_json() {
611        use crate::cli::address_book::{AddressBook, WatchedAddress};
612
613        let temp_dir = TempDir::new().unwrap();
614        let data_dir = temp_dir.path().to_path_buf();
615        let output_path = temp_dir.path().join("address_book.json");
616
617        // Create a test address book
618        let address_book = AddressBook {
619            addresses: vec![WatchedAddress {
620                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
621                label: Some("Test".to_string()),
622                chain: "ethereum".to_string(),
623                tags: vec![],
624                added_at: 1700000000,
625            }],
626        };
627        address_book.save(&data_dir).unwrap();
628
629        let config = Config {
630            address_book: crate::config::AddressBookConfig {
631                data_dir: Some(data_dir),
632            },
633            ..Default::default()
634        };
635
636        let args = ExportArgs {
637            address: None,
638            address_book: true,
639            output: output_path.clone(),
640            format: Some(OutputFormat::Json),
641            chain: "ethereum".to_string(),
642            from: None,
643            to: None,
644            limit: 1000,
645        };
646
647        let result = export_address_book(&args, OutputFormat::Json, &config).await;
648        assert!(result.is_ok());
649        assert!(output_path.exists());
650
651        let content = std::fs::read_to_string(&output_path).unwrap();
652        assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
653    }
654
655    #[tokio::test]
656    async fn test_export_address_book_csv() {
657        use crate::cli::address_book::{AddressBook, WatchedAddress};
658
659        let temp_dir = TempDir::new().unwrap();
660        let data_dir = temp_dir.path().to_path_buf();
661        let output_path = temp_dir.path().join("address_book.csv");
662
663        let address_book = AddressBook {
664            addresses: vec![WatchedAddress {
665                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
666                label: Some("Test Wallet".to_string()),
667                chain: "ethereum".to_string(),
668                tags: vec!["personal".to_string()],
669                added_at: 1700000000,
670            }],
671        };
672        address_book.save(&data_dir).unwrap();
673
674        let config = Config {
675            address_book: crate::config::AddressBookConfig {
676                data_dir: Some(data_dir),
677            },
678            ..Default::default()
679        };
680
681        let args = ExportArgs {
682            address: None,
683            address_book: true,
684            output: output_path.clone(),
685            format: Some(OutputFormat::Csv),
686            chain: "ethereum".to_string(),
687            from: None,
688            to: None,
689            limit: 1000,
690        };
691
692        let result = export_address_book(&args, OutputFormat::Csv, &config).await;
693        assert!(result.is_ok());
694
695        let content = std::fs::read_to_string(&output_path).unwrap();
696        assert!(content.contains("address,label,chain,tags,added_at"));
697        assert!(content.contains("Test Wallet"));
698        assert!(content.contains("personal"));
699    }
700
701    #[tokio::test]
702    async fn test_export_address_book_markdown() {
703        use crate::cli::address_book::{AddressBook, WatchedAddress};
704
705        let temp_dir = TempDir::new().unwrap();
706        let data_dir = temp_dir.path().to_path_buf();
707        let output_path = temp_dir.path().join("address_book.md");
708
709        let address_book = AddressBook {
710            addresses: vec![WatchedAddress {
711                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
712                label: Some("Test Wallet".to_string()),
713                chain: "ethereum".to_string(),
714                tags: vec!["personal".to_string(), "trading".to_string()],
715                added_at: 1700000000,
716            }],
717        };
718        address_book.save(&data_dir).unwrap();
719
720        let config = Config {
721            address_book: crate::config::AddressBookConfig {
722                data_dir: Some(data_dir),
723            },
724            ..Default::default()
725        };
726
727        let args = ExportArgs {
728            address: None,
729            address_book: true,
730            output: output_path.clone(),
731            format: Some(OutputFormat::Markdown),
732            chain: "ethereum".to_string(),
733            from: None,
734            to: None,
735            limit: 1000,
736        };
737
738        let result = export_address_book(&args, OutputFormat::Markdown, &config).await;
739        assert!(result.is_ok());
740        assert!(output_path.exists());
741
742        let content = std::fs::read_to_string(&output_path).unwrap();
743        assert!(content.contains("# Address Book Export"));
744        assert!(content.contains("| Address | Label | Chain | Tags | Added |"));
745        assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
746        assert!(content.contains("Test Wallet"));
747        assert!(content.contains("personal, trading"));
748    }
749
750    // ========================================================================
751    // Date parsing and pure function tests
752    // ========================================================================
753
754    #[test]
755    fn test_parse_date_to_ts_valid() {
756        let ts = parse_date_to_ts("2024-01-01");
757        assert!(ts.is_some());
758        let ts = ts.unwrap();
759        // Jan 1, 2024 00:00:00 UTC should be around 1704067200
760        assert!(ts > 1700000000 && ts < 1710000000);
761    }
762
763    #[test]
764    fn test_parse_date_to_ts_epoch() {
765        let ts = parse_date_to_ts("1970-01-01");
766        assert_eq!(ts, Some(0));
767    }
768
769    #[test]
770    fn test_parse_date_to_ts_invalid_format() {
771        assert!(parse_date_to_ts("not-a-date").is_none());
772        assert!(parse_date_to_ts("2024/01/01").is_none());
773        assert!(parse_date_to_ts("2024-01").is_none());
774        assert!(parse_date_to_ts("").is_none());
775    }
776
777    #[test]
778    fn test_parse_date_to_ts_invalid_values() {
779        assert!(parse_date_to_ts("2024-13-01").is_none()); // Month > 12
780        assert!(parse_date_to_ts("2024-00-01").is_none()); // Month 0
781        assert!(parse_date_to_ts("2024-01-00").is_none()); // Day 0
782        assert!(parse_date_to_ts("2024-01-32").is_none()); // Day > 31
783    }
784
785    #[test]
786    fn test_days_since_epoch_basic() {
787        // Jan 1, 1970 should be day 0
788        let days = days_since_epoch(1970, 1, 1);
789        assert_eq!(days, Some(0));
790    }
791
792    #[test]
793    fn test_days_since_epoch_known_date() {
794        // 2000-01-01 is day 10957
795        let days = days_since_epoch(2000, 1, 1);
796        assert_eq!(days, Some(10957));
797    }
798
799    #[test]
800    fn test_days_since_epoch_invalid_month() {
801        assert!(days_since_epoch(2024, 13, 1).is_none());
802        assert!(days_since_epoch(2024, 0, 1).is_none());
803    }
804
805    #[test]
806    fn test_days_since_epoch_invalid_day() {
807        assert!(days_since_epoch(2024, 1, 0).is_none());
808        assert!(days_since_epoch(2024, 1, 32).is_none());
809    }
810
811    #[tokio::test]
812    async fn test_export_address_book_table_error() {
813        let temp_dir = TempDir::new().unwrap();
814        let data_dir = temp_dir.path().to_path_buf();
815        let output_path = temp_dir.path().join("output.txt");
816
817        // Create empty address book
818        use crate::cli::address_book::AddressBook;
819        let address_book = AddressBook { addresses: vec![] };
820        address_book.save(&data_dir).unwrap();
821
822        let config = Config {
823            address_book: crate::config::AddressBookConfig {
824                data_dir: Some(data_dir),
825            },
826            ..Default::default()
827        };
828
829        let args = ExportArgs {
830            address: None,
831            address_book: true,
832            output: output_path,
833            format: Some(OutputFormat::Table),
834            chain: "ethereum".to_string(),
835            from: None,
836            to: None,
837            limit: 1000,
838        };
839
840        let result = export_address_book(&args, OutputFormat::Table, &config).await;
841        assert!(result.is_err()); // Table format not supported for export
842    }
843
844    #[tokio::test]
845    async fn test_run_no_source_error() {
846        let config = Config::default();
847        let args = ExportArgs {
848            address: None,
849            address_book: false,
850            output: PathBuf::from("output.json"),
851            format: None,
852            chain: "ethereum".to_string(),
853            from: None,
854            to: None,
855            limit: 1000,
856        };
857
858        let http: std::sync::Arc<dyn crate::http::HttpClient> =
859            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
860        let factory = crate::chains::DefaultClientFactory {
861            chains_config: crate::config::ChainsConfig::default(),
862            http,
863        };
864        let result = run(args, &config, &factory).await;
865        assert!(result.is_err());
866    }
867
868    // ========================================================================
869    // End-to-end tests using MockClientFactory
870    // ========================================================================
871
872    use crate::chains::mocks::{MockChainClient, MockClientFactory};
873
874    fn mock_factory() -> MockClientFactory {
875        let mut factory = MockClientFactory::new();
876        factory.mock_client = MockChainClient::new("ethereum", "ETH");
877        factory.mock_client.transactions = vec![crate::chains::Transaction {
878            hash: "0xexport1".to_string(),
879            block_number: Some(100),
880            timestamp: Some(1700000000),
881            from: "0xfrom".to_string(),
882            to: Some("0xto".to_string()),
883            value: "1.0".to_string(),
884            gas_limit: 21000,
885            gas_used: Some(21000),
886            gas_price: "20000000000".to_string(),
887            nonce: 0,
888            input: "0x".to_string(),
889            status: Some(true),
890        }];
891        factory
892    }
893
894    #[tokio::test]
895    async fn test_run_export_address_json() {
896        let config = Config::default();
897        let factory = mock_factory();
898        let tmp = tempfile::NamedTempFile::new().unwrap();
899        let args = ExportArgs {
900            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
901            address_book: false,
902            output: tmp.path().to_path_buf(),
903            format: Some(OutputFormat::Json),
904            chain: "ethereum".to_string(),
905            from: None,
906            to: None,
907            limit: 100,
908        };
909        let result = run(args, &config, &factory).await;
910        assert!(result.is_ok());
911        // Verify file was written
912        let content = std::fs::read_to_string(tmp.path()).unwrap();
913        assert!(content.contains("0xexport1"));
914    }
915
916    #[tokio::test]
917    async fn test_run_export_address_csv() {
918        let config = Config::default();
919        let factory = mock_factory();
920        let tmp = tempfile::NamedTempFile::new().unwrap();
921        let args = ExportArgs {
922            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
923            address_book: false,
924            output: tmp.path().to_path_buf(),
925            format: Some(OutputFormat::Csv),
926            chain: "ethereum".to_string(),
927            from: None,
928            to: None,
929            limit: 100,
930        };
931        let result = run(args, &config, &factory).await;
932        assert!(result.is_ok());
933        let content = std::fs::read_to_string(tmp.path()).unwrap();
934        assert!(content.contains("hash,block,timestamp"));
935    }
936
937    #[tokio::test]
938    async fn test_run_export_address_non_ethereum_chain() {
939        let config = Config::default();
940        let mut factory = MockClientFactory::new();
941        factory.mock_client = MockChainClient::new("polygon", "MATIC");
942        factory.mock_client.transactions = vec![crate::chains::Transaction {
943            hash: "0xpolygon".to_string(),
944            block_number: Some(200),
945            timestamp: Some(1700000000),
946            from: "0xfrom".to_string(),
947            to: Some("0xto".to_string()),
948            value: "2.0".to_string(),
949            gas_limit: 21000,
950            gas_used: Some(21000),
951            gas_price: "20000000000".to_string(),
952            nonce: 0,
953            input: "0x".to_string(),
954            status: Some(true),
955        }];
956        let tmp = tempfile::NamedTempFile::new().unwrap();
957        let args = ExportArgs {
958            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
959            address_book: false,
960            output: tmp.path().to_path_buf(),
961            format: Some(OutputFormat::Json),
962            chain: "polygon".to_string(), // Non-ethereum chain
963            from: None,
964            to: None,
965            limit: 100,
966        };
967        let result = run(args, &config, &factory).await;
968        assert!(result.is_ok());
969        let content = std::fs::read_to_string(tmp.path()).unwrap();
970        assert!(content.contains("polygon"));
971        assert!(content.contains("0xpolygon"));
972    }
973
974    #[tokio::test]
975    async fn test_run_export_with_date_filter() {
976        let config = Config::default();
977        let factory = mock_factory();
978        let tmp = tempfile::NamedTempFile::new().unwrap();
979        let args = ExportArgs {
980            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
981            address_book: false,
982            output: tmp.path().to_path_buf(),
983            format: Some(OutputFormat::Json),
984            chain: "ethereum".to_string(),
985            from: Some("2023-01-01".to_string()),
986            to: Some("2025-12-31".to_string()),
987            limit: 100,
988        };
989        let result = run(args, &config, &factory).await;
990        assert!(result.is_ok());
991    }
992
993    #[tokio::test]
994    async fn test_run_export_address_markdown() {
995        let config = Config::default();
996        let factory = mock_factory();
997        let tmp = tempfile::NamedTempFile::new().unwrap();
998        let args = ExportArgs {
999            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1000            address_book: false,
1001            output: tmp.path().to_path_buf(),
1002            format: Some(OutputFormat::Markdown),
1003            chain: "ethereum".to_string(),
1004            from: None,
1005            to: None,
1006            limit: 100,
1007        };
1008        let result = run(args, &config, &factory).await;
1009        assert!(result.is_ok());
1010        let content = std::fs::read_to_string(tmp.path()).unwrap();
1011        assert!(content.contains("# Transaction Export"));
1012        assert!(
1013            content.contains("| Hash | Block | Timestamp | From | To | Value | Gas | Status |")
1014        );
1015        assert!(content.contains("0xexport1"));
1016    }
1017
1018    #[tokio::test]
1019    async fn test_run_export_address_table_error() {
1020        let config = Config::default();
1021        let factory = mock_factory();
1022        let tmp = tempfile::NamedTempFile::new().unwrap();
1023        let args = ExportArgs {
1024            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1025            address_book: false,
1026            output: tmp.path().to_path_buf(),
1027            format: Some(OutputFormat::Table),
1028            chain: "ethereum".to_string(),
1029            from: None,
1030            to: None,
1031            limit: 100,
1032        };
1033        let result = run(args, &config, &factory).await;
1034        assert!(result.is_err()); // Table format not supported for export
1035    }
1036
1037    #[tokio::test]
1038    async fn test_run_export_address_with_date_filter_before() {
1039        let config = Config::default();
1040        let mut factory = MockClientFactory::new();
1041        factory.mock_client = MockChainClient::new("ethereum", "ETH");
1042        // Transaction with timestamp 1700000000 (2023-11-14)
1043        factory.mock_client.transactions = vec![crate::chains::Transaction {
1044            hash: "0xbefore".to_string(),
1045            block_number: Some(100),
1046            timestamp: Some(1690000000), // Before filter
1047            from: "0xfrom".to_string(),
1048            to: Some("0xto".to_string()),
1049            value: "1.0".to_string(),
1050            gas_limit: 21000,
1051            gas_used: Some(21000),
1052            gas_price: "20000000000".to_string(),
1053            nonce: 0,
1054            input: "0x".to_string(),
1055            status: Some(true),
1056        }];
1057        let tmp = tempfile::NamedTempFile::new().unwrap();
1058        let args = ExportArgs {
1059            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1060            address_book: false,
1061            output: tmp.path().to_path_buf(),
1062            format: Some(OutputFormat::Json),
1063            chain: "ethereum".to_string(),
1064            from: Some("2024-01-01".to_string()), // Filter: only after 2024-01-01
1065            to: None,
1066            limit: 100,
1067        };
1068        let result = run(args, &config, &factory).await;
1069        assert!(result.is_ok());
1070        let content = std::fs::read_to_string(tmp.path()).unwrap();
1071        // Transaction should be filtered out (before from date)
1072        assert!(!content.contains("0xbefore"));
1073    }
1074
1075    #[tokio::test]
1076    async fn test_run_export_address_with_date_filter_after() {
1077        let config = Config::default();
1078        let mut factory = MockClientFactory::new();
1079        factory.mock_client = MockChainClient::new("ethereum", "ETH");
1080        // Transaction with timestamp 1800000000 (2027-01-14)
1081        factory.mock_client.transactions = vec![crate::chains::Transaction {
1082            hash: "0xafter".to_string(),
1083            block_number: Some(100),
1084            timestamp: Some(1800000000), // After filter
1085            from: "0xfrom".to_string(),
1086            to: Some("0xto".to_string()),
1087            value: "1.0".to_string(),
1088            gas_limit: 21000,
1089            gas_used: Some(21000),
1090            gas_price: "20000000000".to_string(),
1091            nonce: 0,
1092            input: "0x".to_string(),
1093            status: Some(true),
1094        }];
1095        let tmp = tempfile::NamedTempFile::new().unwrap();
1096        let args = ExportArgs {
1097            address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1098            address_book: false,
1099            output: tmp.path().to_path_buf(),
1100            format: Some(OutputFormat::Json),
1101            chain: "ethereum".to_string(),
1102            from: None,
1103            to: Some("2025-12-31".to_string()), // Filter: only before 2025-12-31
1104            limit: 100,
1105        };
1106        let result = run(args, &config, &factory).await;
1107        assert!(result.is_ok());
1108        let content = std::fs::read_to_string(tmp.path()).unwrap();
1109        // Transaction should be filtered out (after to date)
1110        assert!(!content.contains("0xafter"));
1111    }
1112
1113    // ========================================================================
1114    // Debug trait tests
1115    // ========================================================================
1116
1117    #[test]
1118    fn test_export_args_debug() {
1119        let args = ExportArgs {
1120            address: Some("0xtest".to_string()),
1121            address_book: false,
1122            output: PathBuf::from("test.json"),
1123            format: Some(OutputFormat::Json),
1124            chain: "ethereum".to_string(),
1125            from: None,
1126            to: None,
1127            limit: 100,
1128        };
1129        let debug_str = format!("{:?}", args);
1130        assert!(debug_str.contains("ExportArgs"));
1131        assert!(debug_str.contains("0xtest"));
1132    }
1133
1134    #[test]
1135    fn test_export_report_debug() {
1136        let report = ExportReport {
1137            export_type: "address".to_string(),
1138            record_count: 42,
1139            output_path: "/tmp/test.json".to_string(),
1140            format: "json".to_string(),
1141            exported_at: 1700000000,
1142        };
1143        let debug_str = format!("{:?}", report);
1144        assert!(debug_str.contains("ExportReport"));
1145        assert!(debug_str.contains("address"));
1146        assert!(debug_str.contains("42"));
1147    }
1148
1149    #[test]
1150    fn test_transaction_export_debug() {
1151        let tx = TransactionExport {
1152            hash: "0xabc123".to_string(),
1153            block_number: 12345,
1154            timestamp: 1700000000,
1155            from: "0xfrom".to_string(),
1156            to: Some("0xto".to_string()),
1157            value: "1.5".to_string(),
1158            gas_used: 21000,
1159            status: true,
1160        };
1161        let debug_str = format!("{:?}", tx);
1162        assert!(debug_str.contains("TransactionExport"));
1163        assert!(debug_str.contains("0xabc123"));
1164    }
1165
1166    #[test]
1167    fn test_transaction_export_debug_no_to() {
1168        let tx = TransactionExport {
1169            hash: "0xcreate".to_string(),
1170            block_number: 100,
1171            timestamp: 1700000000,
1172            from: "0xdeployer".to_string(),
1173            to: None,
1174            value: "0".to_string(),
1175            gas_used: 500000,
1176            status: true,
1177        };
1178        let debug_str = format!("{:?}", tx);
1179        assert!(debug_str.contains("TransactionExport"));
1180        assert!(debug_str.contains("0xcreate"));
1181    }
1182
1183    #[test]
1184    fn test_export_data_debug() {
1185        let data = ExportData {
1186            address: "0xtest".to_string(),
1187            chain: "ethereum".to_string(),
1188            transactions: vec![],
1189            exported_at: 1700000000,
1190        };
1191        let debug_str = format!("{:?}", data);
1192        assert!(debug_str.contains("ExportData"));
1193        assert!(debug_str.contains("0xtest"));
1194        assert!(debug_str.contains("ethereum"));
1195    }
1196
1197    #[test]
1198    fn test_export_data_debug_with_transactions() {
1199        let data = ExportData {
1200            address: "0xtest".to_string(),
1201            chain: "ethereum".to_string(),
1202            transactions: vec![TransactionExport {
1203                hash: "0xabc".to_string(),
1204                block_number: 1,
1205                timestamp: 0,
1206                from: "0x1".to_string(),
1207                to: Some("0x2".to_string()),
1208                value: "0".to_string(),
1209                gas_used: 21000,
1210                status: true,
1211            }],
1212            exported_at: 1700000000,
1213        };
1214        let debug_str = format!("{:?}", data);
1215        assert!(debug_str.contains("ExportData"));
1216        assert!(debug_str.contains("0xabc"));
1217    }
1218
1219    // ========================================================================
1220    // Additional pure function tests
1221    // ========================================================================
1222
1223    #[test]
1224    fn test_detect_format_markdown() {
1225        let path = PathBuf::from("output.md");
1226        // Markdown extension should default to JSON (not explicitly handled)
1227        assert_eq!(detect_format(&path), OutputFormat::Json);
1228    }
1229
1230    #[test]
1231    fn test_detect_format_txt() {
1232        let path = PathBuf::from("output.txt");
1233        assert_eq!(detect_format(&path), OutputFormat::Json);
1234    }
1235
1236    #[test]
1237    fn test_parse_date_to_ts_future_date() {
1238        let ts = parse_date_to_ts("2100-01-01");
1239        assert!(ts.is_some());
1240        let ts = ts.unwrap();
1241        // Should be a large timestamp
1242        assert!(ts > 4000000000);
1243    }
1244
1245    #[test]
1246    fn test_parse_date_to_ts_leap_year() {
1247        let ts = parse_date_to_ts("2024-02-29");
1248        assert!(ts.is_some());
1249    }
1250
1251    #[test]
1252    fn test_parse_date_to_ts_non_leap_year_feb_29() {
1253        // 2023 is not a leap year, but our simple function doesn't validate this
1254        // It will still return a value, just potentially incorrect
1255        let ts = parse_date_to_ts("2023-02-29");
1256        // The function doesn't validate leap years, so it may return Some
1257        // or None depending on implementation
1258        let _ = ts;
1259    }
1260
1261    #[test]
1262    fn test_days_since_epoch_leap_year() {
1263        let days = days_since_epoch(2024, 2, 29);
1264        assert!(days.is_some());
1265    }
1266
1267    #[test]
1268    fn test_days_since_epoch_year_before_epoch() {
1269        let days = days_since_epoch(1969, 12, 31);
1270        assert!(days.is_some());
1271        assert!(days.unwrap() < 0);
1272    }
1273
1274    #[test]
1275    fn test_days_since_epoch_future_year() {
1276        let days = days_since_epoch(2100, 1, 1);
1277        assert!(days.is_some());
1278        assert!(days.unwrap() > 0);
1279    }
1280}