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